diff --git a/CHANGELOG.md b/CHANGELOG.md index f15d6dea..2a5e96e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -194,6 +194,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `~/.deepseek/audit.log` can correlate large-output episodes with disk-usage growth in `~/.deepseek/tool_outputs/`. Fires in both the sequential and parallel tool paths. +- **Prompt stash** (#440) — Ctrl+S in the composer parks the + current draft to a JSONL-backed stash at + `~/.deepseek/composer_stash.jsonl` (no-op on empty composer). + `/stash list` shows parked drafts (oldest first, with one-line + previews and timestamps); `/stash pop` restores the most + recently parked draft into the composer (LIFO). Self-healing + parser drops malformed lines instead of poisoning the stash. + Capped at 200 entries; multiline drafts round-trip intact via + JSON's newline escaping. - **RLM tool family** (#512) — `rlm` tool cards map to `ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm" wording cleaned out of docs / comments / tests. diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index db220021..51283cdd 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -20,6 +20,7 @@ mod review; mod session; pub mod share; mod skills; +mod stash; mod task; mod user_commands; @@ -461,6 +462,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "models" => core::models(app), "provider" => provider::provider(app, arg), "queue" | "queued" => queue::queue(app, arg), + "stash" | "park" => stash::stash(app, arg), "subagents" | "agents" => core::subagents(app), "links" | "dashboard" | "api" => core::deepseek_links(app), "home" | "stats" | "overview" => core::home_dashboard(app), diff --git a/crates/tui/src/commands/stash.rs b/crates/tui/src/commands/stash.rs new file mode 100644 index 00000000..f0269315 --- /dev/null +++ b/crates/tui/src/commands/stash.rs @@ -0,0 +1,110 @@ +//! `/stash` slash command — list / pop parked composer drafts (#440). +//! +//! See `crates/tui/src/composer_stash.rs` for the on-disk format +//! and persistence rules. The slash command is the user-facing +//! surface; Ctrl+S in the composer is the corresponding push entry +//! point. + +use crate::composer_stash; +use crate::tui::app::App; + +use super::CommandResult; + +/// Top-level dispatch for `/stash`. Subcommands: +/// +/// * `/stash` — same as `/stash list`. +/// * `/stash list` — show parked drafts, oldest first. +/// * `/stash pop` — restore the most recently parked draft into +/// the composer; the popped entry is removed from disk. +pub fn stash(app: &mut App, arg: Option<&str>) -> CommandResult { + let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase(); + match sub.as_str() { + "" | "list" | "ls" | "show" => list(), + "pop" | "restore" => pop(app), + other => CommandResult::error(format!( + "unknown subcommand `{other}`. Try `/stash list` or `/stash pop`." + )), + } +} + +fn list() -> CommandResult { + let entries = composer_stash::load_stash(); + if entries.is_empty() { + return CommandResult::message( + "Stash empty. Press Ctrl+S in the composer to park the current draft.", + ); + } + let mut out = String::new(); + out.push_str(&format!("{} parked draft(s):\n\n", entries.len())); + for (idx, entry) in entries.iter().enumerate() { + let preview = preview_first_line(&entry.text, 80); + let ts = if entry.ts.is_empty() { + "(no ts)".to_string() + } else { + entry.ts.clone() + }; + out.push_str(&format!(" {idx}. [{ts}] {preview}\n")); + } + out.push_str("\nUse `/stash pop` to restore the most recent draft."); + CommandResult::message(out) +} + +fn pop(app: &mut App) -> CommandResult { + match composer_stash::pop_stash() { + Some(entry) => { + // Replace the current composer contents with the popped + // draft. We don't merge — replacing is the predictable + // behaviour and matches the "restore the parked draft" + // mental model. Mirror the queue-edit pattern for the + // cursor reset. + app.input = entry.text.clone(); + app.cursor_position = app.input.len(); + let preview = preview_first_line(&entry.text, 60); + CommandResult::message(format!("Restored stashed draft: {preview}")) + } + None => CommandResult::message("Stash empty — nothing to pop."), + } +} + +/// Take a one-line preview of `text`, capped at `max_chars`. +/// Multi-line drafts get a single-line summary so the listing +/// stays scannable. +fn preview_first_line(text: &str, max_chars: usize) -> String { + let head = text.lines().next().unwrap_or("").trim(); + if head.chars().count() <= max_chars { + return head.to_string(); + } + let mut out: String = head.chars().take(max_chars.saturating_sub(1)).collect(); + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_first_line_truncates_to_cap() { + let body = "x".repeat(200); + let p = preview_first_line(&body, 10); + assert_eq!(p.chars().count(), 10); + assert!(p.ends_with('…')); + } + + #[test] + fn preview_first_line_keeps_short_input_intact() { + assert_eq!(preview_first_line("short", 50), "short"); + } + + #[test] + fn preview_first_line_only_uses_first_line_of_multiline() { + let body = "first line of the draft\nsecond line that's longer\nthird"; + assert_eq!(preview_first_line(body, 80), "first line of the draft"); + } + + #[test] + fn preview_first_line_handles_empty_input() { + assert_eq!(preview_first_line("", 50), ""); + assert_eq!(preview_first_line(" ", 50), ""); + } +} diff --git a/crates/tui/src/composer_stash.rs b/crates/tui/src/composer_stash.rs new file mode 100644 index 00000000..8e7d37dc --- /dev/null +++ b/crates/tui/src/composer_stash.rs @@ -0,0 +1,254 @@ +//! Parked-draft stash for the composer (#440). +//! +//! A stash is a side-channel from history: it holds drafts the user +//! parked deliberately (Ctrl+S) instead of submissions made in the +//! past (which live in `composer_history.rs`). Pop semantics make it +//! a LIFO — the most recent stash comes back first. +//! +//! ## On-disk format +//! +//! `~/.deepseek/composer_stash.jsonl` — one JSON object per line: +//! +//! ```jsonl +//! {"ts":"2026-05-04T01:23:45Z","text":"draft here"} +//! ``` +//! +//! Self-healing parser: malformed lines are skipped silently so a +//! single bad write doesn't corrupt the rest of the stash. The +//! parser doesn't require any specific field order; only `text` is +//! mandatory. +//! +//! ## Why JSONL and not a plain text file? +//! +//! Drafts can contain newlines (they're prompts, not single-line +//! commands), so a `\n`-delimited plain file would mangle multi-line +//! drafts. JSONL escapes newlines inside JSON strings without +//! ambiguity and the timestamp / future fields land cleanly. + +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +const STASH_FILE_NAME: &str = "composer_stash.jsonl"; + +/// Hard cap so a runaway script can't fill the user's home with +/// parked drafts. Older entries are pruned at push time when the +/// stash exceeds this count. +pub const MAX_STASH_ENTRIES: usize = 200; + +/// One parked draft. Fields are `#[serde(default)]` so legacy / +/// truncated records still parse instead of poisoning the stash. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StashedDraft { + /// RFC 3339 timestamp; omitted on legacy records. + #[serde(default)] + pub ts: String, + /// The parked text. Required — entries with no `text` are + /// dropped during load (treated as malformed). + pub text: String, +} + +fn default_stash_path() -> Option { + dirs::home_dir().map(|home| home.join(".deepseek").join(STASH_FILE_NAME)) +} + +/// Load every stashed draft from disk in the order they were +/// written (oldest first). Self-healing: malformed lines are +/// dropped silently. Returns an empty vec when the file doesn't +/// exist. +#[must_use] +pub fn load_stash() -> Vec { + let Some(path) = default_stash_path() else { + return Vec::new(); + }; + load_stash_from(&path) +} + +fn load_stash_from(path: &Path) -> Vec { + let Ok(file) = fs::File::open(path) else { + return Vec::new(); + }; + BufReader::new(file) + .lines() + .map_while(Result::ok) + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(&line).ok()) + .filter(|draft| !draft.text.is_empty()) + .collect() +} + +/// Push a new draft onto the stash. Empty / whitespace-only text +/// is silently dropped so a stray Ctrl+S on an empty composer +/// doesn't pollute the file. Failures are logged but never +/// propagated — stash is a UX nicety, not a correctness concern. +pub fn push_stash(text: &str) { + let Some(path) = default_stash_path() else { + return; + }; + push_stash_to(&path, text); +} + +fn push_stash_to(path: &Path, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + if let Some(parent) = path.parent() + && let Err(err) = fs::create_dir_all(parent) + { + tracing::warn!( + "Failed to create composer stash dir {}: {err}", + parent.display() + ); + return; + } + + let mut entries = load_stash_from(path); + entries.push(StashedDraft { + ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + text: text.to_string(), + }); + if entries.len() > MAX_STASH_ENTRIES { + let excess = entries.len() - MAX_STASH_ENTRIES; + entries.drain(0..excess); + } + write_stash_to(path, &entries); +} + +/// Remove and return the most recently pushed draft, if any. +/// Rewrites the on-disk file with the remaining entries. +#[must_use] +pub fn pop_stash() -> Option { + let path = default_stash_path()?; + pop_stash_from(&path) +} + +fn pop_stash_from(path: &Path) -> Option { + let mut entries = load_stash_from(path); + let popped = entries.pop()?; + write_stash_to(path, &entries); + Some(popped) +} + +fn write_stash_to(path: &Path, entries: &[StashedDraft]) { + let mut payload = String::new(); + for entry in entries { + match serde_json::to_string(entry) { + Ok(line) => { + payload.push_str(&line); + payload.push('\n'); + } + Err(err) => { + // A draft that round-trips through serde shouldn't + // fail to serialize, but belt-and-suspenders so a + // weird codepoint in `text` doesn't blow the file + // away mid-write. + tracing::warn!("Skipping stash entry due to serialize failure: {err}"); + } + } + } + if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) { + tracing::warn!( + "Failed to persist composer stash at {}: {err}", + path.display() + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn temp_stash_path() -> (TempDir, PathBuf) { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("composer_stash.jsonl"); + (tmp, path) + } + + #[test] + fn push_and_load_round_trip() { + let (_tmp, path) = temp_stash_path(); + push_stash_to(&path, "first draft"); + push_stash_to(&path, "second draft"); + let entries = load_stash_from(&path); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].text, "first draft"); + assert_eq!(entries[1].text, "second draft"); + assert!(!entries[1].ts.is_empty(), "timestamp stamped on push"); + } + + #[test] + fn pop_returns_lifo_and_rewrites_file() { + let (_tmp, path) = temp_stash_path(); + push_stash_to(&path, "first"); + push_stash_to(&path, "second"); + let popped = pop_stash_from(&path).expect("non-empty stash"); + assert_eq!(popped.text, "second"); + let remaining = load_stash_from(&path); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].text, "first"); + } + + #[test] + fn pop_on_empty_stash_returns_none() { + let (_tmp, path) = temp_stash_path(); + assert!(pop_stash_from(&path).is_none()); + } + + #[test] + fn empty_text_is_dropped() { + let (_tmp, path) = temp_stash_path(); + push_stash_to(&path, ""); + push_stash_to(&path, " \n "); + assert!(load_stash_from(&path).is_empty()); + } + + #[test] + fn multiline_drafts_are_preserved_intact() { + let (_tmp, path) = temp_stash_path(); + let multiline = "first line\nsecond line\n third line"; + push_stash_to(&path, multiline); + let entries = load_stash_from(&path); + assert_eq!(entries.len(), 1); + // Multi-line text round-trips because JSON escapes the newlines. + assert_eq!(entries[0].text, multiline); + } + + #[test] + fn malformed_lines_are_skipped_and_valid_lines_survive() { + let (_tmp, path) = temp_stash_path(); + // Mix of valid JSON, garbage, and partial-write truncation. + let raw = "\ +{\"ts\":\"2026-05-04T01:23:45Z\",\"text\":\"good one\"} +this is not json +{\"text\":\"good two\"} +{\"ts\":\"2026-05-04T01:24:00Z\" +{\"text\":\"\"} +{} +"; + std::fs::write(&path, raw).unwrap(); + let entries = load_stash_from(&path); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].text, "good one"); + assert_eq!(entries[1].text, "good two"); + } + + #[test] + fn cap_prunes_oldest_at_push_time() { + let (_tmp, path) = temp_stash_path(); + for i in 0..(MAX_STASH_ENTRIES + 5) { + push_stash_to(&path, &format!("draft {i}")); + } + let entries = load_stash_from(&path); + assert_eq!(entries.len(), MAX_STASH_ENTRIES); + // Oldest survivors are `5..` because the first 5 were pruned. + assert_eq!(entries[0].text, "draft 5"); + assert_eq!( + entries[entries.len() - 1].text, + format!("draft {}", MAX_STASH_ENTRIES + 5 - 1) + ); + } +} diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index c4323143..216bc587 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -299,6 +299,7 @@ pub enum MessageId { KbJumpLineStartEnd, KbDeleteChar, KbClearDraft, + KbStashDraft, KbSearchHistory, KbInsertNewline, KbSendDraft, @@ -482,6 +483,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::KbJumpLineStartEnd, MessageId::KbDeleteChar, MessageId::KbClearDraft, + MessageId::KbStashDraft, MessageId::KbSearchHistory, MessageId::KbInsertNewline, MessageId::KbSendDraft, @@ -848,6 +850,7 @@ fn english(id: MessageId) -> &'static str { "Delete character before / after the cursor, or remove selected attachment" } MessageId::KbClearDraft => "Clear the current draft", + MessageId::KbStashDraft => "Stash the current draft (`/stash pop` to restore)", MessageId::KbSearchHistory => "Search prompt history and recover local drafts", MessageId::KbInsertNewline => "Insert a newline in the composer", MessageId::KbSendDraft => "Send the current draft", @@ -1117,6 +1120,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::KbJumpLineStartEnd => "行の先頭/末尾へジャンプ", MessageId::KbDeleteChar => "カーソル前/後の文字を削除、または選択中の添付を削除", MessageId::KbClearDraft => "現在の下書きをクリア", + MessageId::KbStashDraft => "現在の下書きをスタッシュ(`/stash pop`で復元)", MessageId::KbSearchHistory => "プロンプト履歴を検索してローカル下書きを復元", MessageId::KbInsertNewline => "コンポーザーに改行を挿入", MessageId::KbSendDraft => "現在の下書きを送信", @@ -1359,6 +1363,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::KbJumpLineStartEnd => "跳转到行首/行尾", MessageId::KbDeleteChar => "删除光标前/后的字符,或移除已选附件", MessageId::KbClearDraft => "清空当前草稿", + MessageId::KbStashDraft => "暂存当前草稿(用 `/stash pop` 恢复)", MessageId::KbSearchHistory => "搜索提示历史并恢复本地草稿", MessageId::KbInsertNewline => "在输入框中插入换行", MessageId::KbSendDraft => "发送当前草稿", @@ -1627,6 +1632,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { "Excluir caractere antes / depois do cursor, ou remover anexo selecionado" } MessageId::KbClearDraft => "Limpar rascunho atual", + MessageId::KbStashDraft => "Estacionar rascunho atual (`/stash pop` restaura)", MessageId::KbSearchHistory => "Buscar histórico de prompts e recuperar rascunhos locais", MessageId::KbInsertNewline => "Inserir nova linha no compositor", MessageId::KbSendDraft => "Enviar rascunho atual", diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index a05047a5..f54ec3c0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -19,6 +19,7 @@ mod command_safety; mod commands; mod compaction; mod composer_history; +mod composer_stash; mod config; mod config_ui; mod core; diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs index 947eed19..7a305cb0 100644 --- a/crates/tui/src/tui/keybindings.rs +++ b/crates/tui/src/tui/keybindings.rs @@ -134,6 +134,11 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ description_id: crate::localization::MessageId::KbClearDraft, section: KeybindingSection::Editing, }, + KeybindingEntry { + chord: "Ctrl+S", + description_id: crate::localization::MessageId::KbStashDraft, + section: KeybindingSection::Editing, + }, KeybindingEntry { chord: "Alt+R", description_id: crate::localization::MessageId::KbSearchHistory, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2782229b..73440e09 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2395,6 +2395,22 @@ async fn run_event_loop( { app.delete_word_backward(); } + KeyCode::Char('s') | KeyCode::Char('S') + if key.modifiers == KeyModifiers::CONTROL && !app.input.is_empty() => + { + // #440: park the current draft to the persistent + // stash and clear the composer. Empty composers + // are a no-op so a stray Ctrl+S can't pollute the + // file. Surface a toast so the user sees the + // confirmation (no-op feels broken otherwise). + crate::composer_stash::push_stash(&app.input); + app.clear_input_recoverable(); + app.push_status_toast( + "Draft stashed — `/stash pop` to restore", + StatusToastLevel::Info, + Some(3_000), + ); + } KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { // #379: context-sensitive Ctrl+Y. // When the composer has content → emacs-style yank