feat(composer): prompt stash — Ctrl+S parks, /stash list+pop (#440)

A stash is a side-channel from history: it holds drafts the user
parked deliberately instead of submissions made in the past
(which live in `composer_history.rs`).

* `crates/tui/src/composer_stash.rs` — JSONL-backed store at
  `~/.deepseek/composer_stash.jsonl`. One JSON object per line
  with `ts` (RFC 3339) and `text`. Self-healing parser drops
  malformed lines instead of poisoning the file. Multi-line
  drafts round-trip intact via JSON's newline escaping. Capped
  at 200 entries; oldest pruned at push time. Empty /
  whitespace-only text is silently dropped.
* `crates/tui/src/commands/stash.rs` — `/stash list` renders the
  stash with one-line previews and timestamps; `/stash pop`
  restores the most recently parked draft into the composer
  (LIFO) and rewrites the file. `/park` aliases `/stash`.
* Composer Ctrl+S handler in `tui/ui.rs` — pushes the current
  draft onto the stash, clears the composer, and surfaces a
  toast confirming the action so the no-op-feel doesn't fool
  users into thinking nothing happened. Empty composers are a
  no-op so a stray Ctrl+S can't pollute the file.
* New `KbStashDraft` keybinding entry registered in the help
  overlay; localized in en, ja, zh-Hans, pt-BR.

Tests:
  7 unit tests in `composer_stash.rs` cover round-trip, LIFO pop,
  empty-on-pop, drop-empty-text, multi-line preservation,
  malformed-line resilience, and cap pruning. 4 unit tests in
  `commands/stash.rs` cover the preview helper's truncation,
  multi-line first-line behavior, and empty-input handling.
This commit is contained in:
Hunter Bown
2026-05-03 06:09:35 -05:00
parent 99223b148c
commit 6fb8739feb
8 changed files with 403 additions and 0 deletions
+9
View File
@@ -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.
+2
View File
@@ -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),
+110
View File
@@ -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), "");
}
}
+254
View File
@@ -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<PathBuf> {
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<StashedDraft> {
let Some(path) = default_stash_path() else {
return Vec::new();
};
load_stash_from(&path)
}
fn load_stash_from(path: &Path) -> Vec<StashedDraft> {
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::<StashedDraft>(&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<StashedDraft> {
let path = default_stash_path()?;
pop_stash_from(&path)
}
fn pop_stash_from(path: &Path) -> Option<StashedDraft> {
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)
);
}
}
+6
View File
@@ -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",
+1
View File
@@ -19,6 +19,7 @@ mod command_safety;
mod commands;
mod compaction;
mod composer_history;
mod composer_stash;
mod config;
mod config_ui;
mod core;
+5
View File
@@ -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,
+16
View File
@@ -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