From 162e2e027cc793f7bd48f0bef309c6a2df8dc8f7 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 2 May 2026 10:32:45 -0500 Subject: [PATCH] feat(composer): cross-session input history persistence (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Up-arrow at the composer now recalls submissions from previous sessions, not just the current one. Implementation: - New `crates/tui/src/composer_history.rs` module with `load_history()` + `append_history()`. Persists to `~/.deepseek/composer_history.txt` (one entry per line, oldest first). Capped at 1000 entries — entries older than the cap are pruned at append time so the file never grows unboundedly. - `App::new` now seeds `input_history` from the persisted file at startup, so Up-arrow at first launch shows yesterday's prompts. - `App::submit_message` mirrors each non-slash submission to the persisted history. Slash commands and empty/whitespace submissions are skipped — those don't help recall and would pollute the stream. - Consecutive-duplicate dedup so re-submitting the same prompt doesn't bloat the file. The persisted history is global (not per-workspace) — matches the arrow-up recall pattern users expect from shells and Claude Code. Per- workspace scoping is a follow-up if multi-project users find it noisy. Tests: 6 unit tests cover round-trip, slash-skip, empty-skip, consecutive-duplicate dedup, cap-pruning, and missing-file safety. The test module uses an internal Mutex to serialize HOME env mutations so tests can still run in parallel without stomping each other. Closes #366. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/composer_history.rs | 193 +++++++++++++++++++++++++++++ crates/tui/src/main.rs | 1 + crates/tui/src/tui/app.rs | 6 +- 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 crates/tui/src/composer_history.rs diff --git a/crates/tui/src/composer_history.rs b/crates/tui/src/composer_history.rs new file mode 100644 index 00000000..d66f2479 --- /dev/null +++ b/crates/tui/src/composer_history.rs @@ -0,0 +1,193 @@ +//! Cross-session composer input history (#366). +//! +//! Persists user-typed prompts to `~/.deepseek/composer_history.txt` so +//! pressing Up-arrow at the composer recalls submissions from previous +//! sessions, not just the current one. One entry per line, oldest first, +//! capped at [`MAX_HISTORY_ENTRIES`] entries (older entries are pruned +//! at append time). +//! +//! Entries that begin with `/` (slash commands) are NOT stored — they +//! pollute the recall stream and the fuzzy slash-menu already covers +//! them. Empty / whitespace-only inputs are also skipped. + +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; + +/// Hard cap on persisted history. Keeps the file small (typical entries +/// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load +/// time. +pub const MAX_HISTORY_ENTRIES: usize = 1000; + +const HISTORY_FILE_NAME: &str = "composer_history.txt"; + +fn history_path() -> Option { + dirs::home_dir().map(|home| home.join(".deepseek").join(HISTORY_FILE_NAME)) +} + +/// Read the persisted history into memory. Returns an empty vec if the +/// file doesn't exist or can't be parsed — this is best-effort. +#[must_use] +pub fn load_history() -> Vec { + let Some(path) = history_path() else { + return Vec::new(); + }; + 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()) + .collect() +} + +/// Append an entry to the persisted history, pruning old entries to +/// stay within [`MAX_HISTORY_ENTRIES`]. Slash-commands and empty input +/// are skipped — those don't help recall. +/// +/// Best-effort — failures are logged via `tracing` but not propagated +/// because composer history is a UX nicety, not a correctness concern. +pub fn append_history(entry: &str) { + let trimmed = entry.trim(); + if trimmed.is_empty() || trimmed.starts_with('/') { + return; + } + let Some(path) = history_path() else { + return; + }; + if let Some(parent) = path.parent() + && let Err(err) = fs::create_dir_all(parent) + { + tracing::warn!( + "Failed to create composer history dir {}: {err}", + parent.display() + ); + return; + } + + // Read existing entries, append the new one, prune from the front + // until under the cap, then atomically rewrite. + let mut entries = load_history(); + if entries.last().map(String::as_str) == Some(trimmed) { + // De-dupe consecutive duplicates — repeated submission of the + // same prompt shouldn't bloat the file. + return; + } + entries.push(trimmed.to_string()); + if entries.len() > MAX_HISTORY_ENTRIES { + let excess = entries.len() - MAX_HISTORY_ENTRIES; + entries.drain(0..excess); + } + + let payload = entries.join("\n") + "\n"; + if let Err(err) = crate::utils::write_atomic(&path, payload.as_bytes()) { + tracing::warn!( + "Failed to persist composer history at {}: {err}", + path.display() + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// Serialise tests in this module — they all mutate the HOME env + /// var, so running in parallel they'd stomp on each other. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_temp_home(f: impl FnOnce() -> R) -> R { + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let tmp = tempfile::tempdir().expect("tempdir"); + let prev = std::env::var_os("HOME"); + // SAFETY: tests in this crate run with single-threaded env mutation + // by harness convention; we restore on exit. + unsafe { std::env::set_var("HOME", tmp.path()) }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + match prev { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + match result { + Ok(r) => r, + Err(p) => std::panic::resume_unwind(p), + } + } + + #[test] + fn append_and_load_round_trip() { + with_temp_home(|| { + append_history("first"); + append_history("second"); + append_history("third"); + let history = load_history(); + assert_eq!(history, vec!["first", "second", "third"]); + }); + } + + #[test] + fn slash_commands_skipped() { + with_temp_home(|| { + append_history("/help"); + append_history("real prompt"); + append_history("/cost"); + let history = load_history(); + assert_eq!(history, vec!["real prompt"]); + }); + } + + #[test] + fn empty_and_whitespace_skipped() { + with_temp_home(|| { + append_history(""); + append_history(" "); + append_history("\n\t"); + append_history("real"); + let history = load_history(); + assert_eq!(history, vec!["real"]); + }); + } + + #[test] + fn consecutive_duplicates_deduped() { + with_temp_home(|| { + append_history("same"); + append_history("same"); + append_history("same"); + append_history("different"); + append_history("same"); + let history = load_history(); + assert_eq!(history, vec!["same", "different", "same"]); + }); + } + + #[test] + fn pruned_to_cap_at_append_time() { + with_temp_home(|| { + for i in 0..(MAX_HISTORY_ENTRIES + 50) { + append_history(&format!("entry {i}")); + } + let history = load_history(); + assert_eq!(history.len(), MAX_HISTORY_ENTRIES); + // Newest entries survive; oldest 50 were pruned. + assert_eq!( + history.first().map(String::as_str), + Some("entry 50") + ); + assert_eq!( + history.last().map(String::as_str), + Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref() + ); + }); + } + + #[test] + fn missing_file_loads_empty() { + with_temp_home(|| { + let history = load_history(); + assert!(history.is_empty()); + }); + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 8de5db3a..07fdb5c4 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -18,6 +18,7 @@ mod client; mod command_safety; mod commands; mod compaction; +mod composer_history; mod config; mod core; mod cycle_manager; diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 26e2abd8..536bde6a 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -968,7 +968,7 @@ impl App { use_bracketed_paste, use_paste_burst_detection, system_prompt: None, - input_history: Vec::new(), + input_history: crate::composer_history::load_history(), draft_history: VecDeque::new(), history_index: None, history_navigation_draft: None, @@ -2524,6 +2524,10 @@ impl App { let excess = self.input_history.len() - self.max_input_history; self.input_history.drain(0..excess); } + // Mirror to the persisted cross-session history (#366) so + // arrow-up recall works across restarts. Best-effort write — + // see `composer_history::append_history` for failure modes. + crate::composer_history::append_history(&input); } self.history_index = None; self.history_navigation_draft = None;