diff --git a/README.md b/README.md index 18914ea..fd04346 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Set `WISP_OUTPUT_DIR` to override where recordings are written. When unset, Wisp - [ ] **Windows support** — exploring WASAPI loopback paired with `Windows.Media.SpeechRecognition` or a local model. - [ ] **Linux support** — exploring PipeWire monitor sources paired with a local Whisper-family model. +- [x] Copy transcript to clipboard and export as plain text (.txt). - [ ] Export to Markdown / SRT / JSON. - [ ] Speaker diarization within a single channel. diff --git a/apps/wisp-desktop/src/app_menu.rs b/apps/wisp-desktop/src/app_menu.rs index fc44082..12baf2d 100644 --- a/apps/wisp-desktop/src/app_menu.rs +++ b/apps/wisp-desktop/src/app_menu.rs @@ -8,12 +8,22 @@ use std::time::Duration; use gpui::{App, Entity, KeyBinding, Menu, MenuItem, actions}; use crate::about_view; -use crate::app::{AppModel, SessionState}; +use crate::app::{AppModel, SessionState, View}; use crate::library::SharedStorage; use crate::session_runner::SessionRunner; use crate::session_updates::apply_update; - -actions!(wisp_desktop, [Quit, About, ToggleRecording]); +use crate::transcript_export::{self, suggested_export_name}; + +actions!( + wisp_desktop, + [ + Quit, + About, + ToggleRecording, + CopyTranscript, + ExportTranscript + ] +); /// Wire up the menu bar, keyboard shortcuts, and quit handlers. pub fn configure( @@ -45,9 +55,31 @@ pub fn configure( crate::toggle_recording(&runner_for_toggle, &model_for_toggle, &recordings_dir, cx); }); + let model_for_copy = model.clone(); + cx.on_action(move |_: &CopyTranscript, cx| { + let app = model_for_copy.read(cx); + if !matches!(app.view, View::LiveSession | View::History { .. }) { + return; + } + transcript_export::copy_transcript_to_clipboard(&app.segments, cx); + }); + + let model_for_export = model.clone(); + cx.on_action(move |_: &ExportTranscript, cx| { + let app = model_for_export.read(cx); + if !matches!(app.view, View::LiveSession | View::History { .. }) { + return; + } + let title = app.viewed_session.as_ref().map(|s| s.title.as_str()); + let name = suggested_export_name(title, "transcript"); + transcript_export::export_transcript(app.segments.clone(), &name, cx); + }); + cx.bind_keys([ KeyBinding::new("cmd-q", Quit, None), KeyBinding::new("cmd-r", ToggleRecording, None), + KeyBinding::new("cmd-shift-c", CopyTranscript, None), + KeyBinding::new("cmd-shift-e", ExportTranscript, None), ]); // The recording item's label flips between "Start" and "Stop" with the @@ -98,6 +130,9 @@ fn build_menus(record_label: &'static str) -> Vec { MenuItem::separator(), MenuItem::action(record_label, ToggleRecording), MenuItem::separator(), + MenuItem::action("Copy Transcript", CopyTranscript), + MenuItem::action("Export Transcript…", ExportTranscript), + MenuItem::separator(), MenuItem::action("Quit Wisp", Quit), ], }] diff --git a/apps/wisp-desktop/src/main.rs b/apps/wisp-desktop/src/main.rs index d177343..dd967ca 100644 --- a/apps/wisp-desktop/src/main.rs +++ b/apps/wisp-desktop/src/main.rs @@ -41,6 +41,7 @@ mod library; mod permissions; mod session_runner; mod session_updates; +mod transcript_export; mod transcript_view; use app::{AppModel, SessionState}; diff --git a/apps/wisp-desktop/src/transcript_export.rs b/apps/wisp-desktop/src/transcript_export.rs new file mode 100644 index 0000000..83e9545 --- /dev/null +++ b/apps/wisp-desktop/src/transcript_export.rs @@ -0,0 +1,178 @@ +//! Format in-memory transcript segments for clipboard copy and file export. + +use std::path::PathBuf; + +use gpui::{App, ClipboardItem}; +use wisp_audiokit::SourceLabel; + +use crate::app::Segment; + +/// Plain-text transcript with one line per segment, sorted by start time. +/// +/// Each non-empty segment becomes `[MIC] …` or `[SYS] …` so pasted text +/// stays readable outside Wisp. +pub fn format_transcript_plain(segments: &[Segment]) -> String { + let mut ordered: Vec<&Segment> = segments + .iter() + .filter(|seg| !seg.text.trim().is_empty()) + .collect(); + ordered.sort_by(|a, b| { + a.start_seconds + .partial_cmp(&b.start_seconds) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + ordered + .into_iter() + .map(|seg| { + let label = match seg.source { + SourceLabel::Mic => "MIC", + SourceLabel::System => "SYS", + }; + format!("[{label}] {}", seg.text.trim()) + }) + .collect::>() + .join("\n\n") +} + +/// Copy the transcript to the system clipboard. +pub fn copy_transcript_to_clipboard( + segments: &[Segment], + cx: &App, +) -> bool { + let text = format_transcript_plain(segments); + if text.is_empty() { + return false; + } + cx.write_to_clipboard(ClipboardItem::new_string(text)); + true +} + +/// Open the platform save dialog and write the transcript to the chosen path. +pub fn export_transcript( + segments: Vec, + suggested_name: &str, + cx: &mut App, +) { + let text = format_transcript_plain(&segments); + if text.is_empty() { + return; + } + + let directory = default_export_directory(); + let suggested = sanitize_filename(suggested_name); + let suggested = format!("{suggested}.txt"); + let rx = cx.prompt_for_new_path(&directory, Some(&suggested)); + + cx.spawn(async move |cx| { + let path = match rx.await { + Ok(Ok(Some(path))) => path, + _ => return, + }; + if let Err(err) = std::fs::write(&path, text.as_bytes()) { + eprintln!( + "wisp: failed to export transcript to {}: {err}", + path.display() + ); + return; + } + let _ = cx.update(|cx| cx.reveal_path(&path)); + }) + .detach(); +} + +/// Default folder for the save dialog — `~/Downloads` when available. +fn default_export_directory() -> PathBuf { + if let Some(home) = std::env::var_os("HOME") { + let home_path = PathBuf::from(&home); + let downloads = home_path.join("Downloads"); + if downloads.is_dir() { + return downloads; + } + return home_path; + } + std::env::temp_dir() +} + +/// Turn a session title into a safe default filename (no extension). +fn sanitize_filename(name: &str) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() { + return "transcript".to_string(); + } + let mut out = String::with_capacity(trimmed.len()); + for c in trimmed.chars() { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + out.push(c); + } else if c.is_whitespace() { + if !out.ends_with('_') { + out.push('_'); + } + } else { + out.push('_'); + } + } + let out = out.trim_matches('_'); + if out.is_empty() { + "transcript".to_string() + } else { + out.to_string() + } +} + +/// Suggested export basename for a live or historical session view. +pub fn suggested_export_name( + title: Option<&str>, + fallback: &str, +) -> String { + title + .map(sanitize_filename) + .unwrap_or_else(|| sanitize_filename(fallback)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::Segment; + + fn seg( + source: SourceLabel, + start: f64, + text: &str, + ) -> Segment { + Segment { + source, + id: 1, + text: text.into(), + start_seconds: start, + end_seconds: start + 1.0, + is_final: true, + } + } + + #[test] + fn formats_segments_in_time_order_with_labels() { + let segments = vec![ + seg(SourceLabel::System, 2.0, "はい"), + seg(SourceLabel::Mic, 1.0, "こんにちは"), + ]; + assert_eq!( + format_transcript_plain(&segments), + "[MIC] こんにちは\n\n[SYS] はい" + ); + } + + #[test] + fn skips_empty_segments() { + let segments = vec![seg(SourceLabel::Mic, 0.0, " ")]; + assert!(format_transcript_plain(&segments).is_empty()); + } + + #[test] + fn sanitize_filename_replaces_unsafe_chars() { + assert_eq!( + sanitize_filename("Meeting 2026/06/02"), + "Meeting_2026_06_02" + ); + } +} diff --git a/apps/wisp-desktop/src/transcript_view.rs b/apps/wisp-desktop/src/transcript_view.rs index 20693c0..28c6b7d 100644 --- a/apps/wisp-desktop/src/transcript_view.rs +++ b/apps/wisp-desktop/src/transcript_view.rs @@ -19,6 +19,7 @@ use wisp_core::{Session as StoredSession, SessionId}; use crate::app::{AppModel, Permissions, Segment, SessionState, View}; use crate::permissions as perms; +use crate::transcript_export::{self, suggested_export_name}; pub struct TranscriptView { pub app: gpui::Entity, @@ -102,24 +103,28 @@ impl Render for TranscriptView { } let view = app.view.clone(); - let library = app.library.clone(); let segments = app.segments.clone(); let active_idx = app.active_segment_index(); let state = app.state; let log_count = app.recent_log.len(); let last_error = app.last_error.clone(); let viewed_session = app.viewed_session.clone(); + let current_session_id = app.current_session_id; + let library = app.library.clone(); match view { View::Library => self.render_library(&library).into_any_element(), View::LiveSession => { self.update_scroll_signature(&segments); + let live_export_title = current_session_id + .and_then(|id| library.iter().find(|s| s.id == id).map(|s| s.title.clone())); self.render_live_session( state, &segments, active_idx, log_count, last_error.as_ref(), + live_export_title.as_deref(), ) .into_any_element() }, @@ -154,14 +159,16 @@ impl TranscriptView { active_idx: Option, log_count: usize, last_error: Option<&SessionError>, + export_title: Option<&str>, ) -> impl IntoElement { + let export_name = suggested_export_name(export_title, "transcript"); div() .flex() .flex_col() .size_full() .bg(theme::bg()) .text_color(theme::text_primary()) - .child(self.render_live_top_bar(state)) + .child(self.render_live_top_bar(state, segments, &export_name)) .child(render_transcript( segments, active_idx, @@ -183,6 +190,7 @@ impl TranscriptView { ) -> impl IntoElement { let title = session.map_or_else(|| "Session".to_string(), |s| s.title.clone()); let subtitle = session.map(history_subtitle); + let export_name = suggested_export_name(Some(&title), "transcript"); div() .flex() @@ -190,7 +198,7 @@ impl TranscriptView { .size_full() .bg(theme::bg()) .text_color(theme::text_primary()) - .child(self.render_history_top_bar(&title, subtitle.as_deref())) + .child(self.render_history_top_bar(&title, subtitle.as_deref(), segments, &export_name)) .child(render_transcript( segments, None, @@ -242,6 +250,8 @@ impl TranscriptView { fn render_live_top_bar( &self, state: SessionState, + segments: &[Segment], + export_name: &str, ) -> impl IntoElement { let toggle = self.on_toggle_record.clone(); let on_back = self.on_back_to_library.clone(); @@ -261,13 +271,22 @@ impl TranscriptView { .child(render_back_button("library-back-live", on_back)) .child(render_brand()), ) - .child(render_record_button(state, toggle)) + .child( + div() + .flex() + .items_center() + .gap(px(8.0)) + .child(render_transcript_actions(segments, export_name)) + .child(render_record_button(state, toggle)), + ) } fn render_history_top_bar( &self, title: &str, subtitle: Option<&str>, + segments: &[Segment], + export_name: &str, ) -> impl IntoElement { let on_back = self.on_back_to_library.clone(); let mut title_block = div().flex().flex_col().gap(px(2.0)).child( @@ -300,6 +319,7 @@ impl TranscriptView { .child(render_back_button("library-back-history", on_back)) .child(title_block), ) + .child(render_transcript_actions(segments, export_name)) } fn render_onboarding( @@ -698,6 +718,58 @@ fn render_new_session_button( }) } +fn render_transcript_actions( + segments: &[Segment], + export_name: &str, +) -> gpui::AnyElement { + if transcript_export::format_transcript_plain(segments).is_empty() { + return div().into_any_element(); + } + + let segments_copy = segments.to_vec(); + let segments_export = segments.to_vec(); + let export_name = export_name.to_string(); + + div() + .flex() + .items_center() + .gap(px(6.0)) + .child(render_toolbar_button( + "transcript-copy", + "Copy", + move |_window, cx| { + transcript_export::copy_transcript_to_clipboard(&segments_copy, cx); + }, + )) + .child(render_toolbar_button("transcript-export", "Export", { + let segments_export = segments_export.clone(); + let export_name = export_name.clone(); + move |_window, cx| { + transcript_export::export_transcript(segments_export.clone(), &export_name, cx); + } + })) + .into_any_element() +} + +fn render_toolbar_button( + id: &'static str, + label: &'static str, + on_click: impl Fn(&mut Window, &mut gpui::App) + 'static, +) -> impl IntoElement { + div() + .id(ElementId::Name(id.into())) + .px(px(12.0)) + .py(px(6.0)) + .rounded_full() + .bg(theme::record_idle()) + .text_color(theme::text_primary()) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .child(label) + .on_click(move |_event, window, cx| on_click(window, cx)) +} + fn render_back_button( id: &'static str, on_click: std::sync::Arc, @@ -853,7 +925,6 @@ fn render_count_status_bar(text: String) -> impl IntoElement { .h(px(32.0)) .flex() .items_center() - .justify_between() .px(px(20.0)) .border_t_1() .border_color(theme::border())