Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
41 changes: 38 additions & 3 deletions apps/wisp-desktop/src/app_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -98,6 +130,9 @@ fn build_menus(record_label: &'static str) -> Vec<Menu> {
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),
],
}]
Expand Down
1 change: 1 addition & 0 deletions apps/wisp-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ mod library;
mod permissions;
mod session_runner;
mod session_updates;
mod transcript_export;
mod transcript_view;

use app::{AppModel, SessionState};
Expand Down
178 changes: 178 additions & 0 deletions apps/wisp-desktop/src/transcript_export.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>()
.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<Segment>,
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"
);
}
}
Loading