feat(composer): cross-session input history persistence (#366)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<PathBuf> {
|
||||
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<String> {
|
||||
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<R>(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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod client;
|
||||
mod command_safety;
|
||||
mod commands;
|
||||
mod compaction;
|
||||
mod composer_history;
|
||||
mod config;
|
||||
mod core;
|
||||
mod cycle_manager;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user