返回 DeepSeek-TUI-2026
composer_history.rs
根目录 / crates / tui / src / composer_history.rs
1 //! Cross-session composer input history (#366).
2 //!
3 //! Persists user-typed prompts to `~/.deepseek/composer_history.txt` so
4 //! pressing Up-arrow at the composer recalls submissions from previous
5 //! sessions, not just the current one. One entry per line, oldest first,
6 //! capped at [`MAX_HISTORY_ENTRIES`] entries (older entries are pruned
7 //! at append time).
8 //!
9 //! Entries that begin with `/` (slash commands) are NOT stored — they
10 //! pollute the recall stream and the fuzzy slash-menu already covers
11 //! them. Empty / whitespace-only inputs are also skipped.
12
13 use std::fs;
14 use std::io::{BufRead, BufReader};
15 use std::path::{Path, PathBuf};
16
17 /// Hard cap on persisted history. Keeps the file small (typical entries
18 /// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load
19 /// time.
20 pub const MAX_HISTORY_ENTRIES: usize = 1000;
21
22 const HISTORY_FILE_NAME: &str = "composer_history.txt";
23
24 fn default_history_path() -> Option<PathBuf> {
25 dirs::home_dir().map(|home| home.join(".deepseek").join(HISTORY_FILE_NAME))
26 }
27
28 /// Read the persisted history into memory. Returns an empty vec if the
29 /// file doesn't exist or can't be parsed — this is best-effort.
30 #[must_use]
31 pub fn load_history() -> Vec<String> {
32 let Some(path) = default_history_path() else {
33 return Vec::new();
34 };
35 load_history_from(&path)
36 }
37
38 fn load_history_from(path: &Path) -> Vec<String> {
39 let Ok(file) = fs::File::open(path) else {
40 return Vec::new();
41 };
42 BufReader::new(file)
43 .lines()
44 .map_while(Result::ok)
45 .filter(|line| !line.trim().is_empty())
46 .collect()
47 }
48
49 /// Append an entry to the persisted history, pruning old entries to
50 /// stay within [`MAX_HISTORY_ENTRIES`]. Slash-commands and empty input
51 /// are skipped — those don't help recall.
52 ///
53 /// Best-effort — failures are logged via `tracing` but not propagated
54 /// because composer history is a UX nicety, not a correctness concern.
55 pub fn append_history(entry: &str) {
56 let Some(path) = default_history_path() else {
57 return;
58 };
59 append_history_to(&path, entry);
60 }
61
62 fn append_history_to(path: &Path, entry: &str) {
63 let trimmed = entry.trim();
64 if trimmed.is_empty() || trimmed.starts_with('/') {
65 return;
66 }
67 if let Some(parent) = path.parent()
68 && let Err(err) = fs::create_dir_all(parent)
69 {
70 tracing::warn!(
71 "Failed to create composer history dir {}: {err}",
72 parent.display()
73 );
74 return;
75 }
76
77 // Read existing entries, append the new one, prune from the front
78 // until under the cap, then atomically rewrite.
79 let mut entries = load_history_from(path);
80 if entries.last().map(String::as_str) == Some(trimmed) {
81 // De-dupe consecutive duplicates — repeated submission of the
82 // same prompt shouldn't bloat the file.
83 return;
84 }
85 entries.push(trimmed.to_string());
86 if entries.len() > MAX_HISTORY_ENTRIES {
87 let excess = entries.len() - MAX_HISTORY_ENTRIES;
88 entries.drain(0..excess);
89 }
90
91 let payload = entries.join("\n") + "\n";
92 if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) {
93 tracing::warn!(
94 "Failed to persist composer history at {}: {err}",
95 path.display()
96 );
97 }
98 }
99
100 #[cfg(test)]
101 mod tests {
102 use super::*;
103
104 /// Tests use the path-injecting `*_from` / `*_to` helpers so they
105 /// don't have to mutate `HOME` (which is not honored by
106 /// `dirs::home_dir()` on Windows — it reads `USERPROFILE` /
107 /// `SHGetKnownFolderPath` instead). This makes the suite portable
108 /// across all three CI runners without per-platform env juggling.
109 fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
110 let tmp = tempfile::tempdir().expect("tempdir");
111 let path = tmp.path().join(HISTORY_FILE_NAME);
112 (tmp, path)
113 }
114
115 #[test]
116 fn append_and_load_round_trip() {
117 let (_tmp, path) = temp_history_path();
118 append_history_to(&path, "first");
119 append_history_to(&path, "second");
120 append_history_to(&path, "third");
121 assert_eq!(load_history_from(&path), vec!["first", "second", "third"]);
122 }
123
124 #[test]
125 fn slash_commands_skipped() {
126 let (_tmp, path) = temp_history_path();
127 append_history_to(&path, "/help");
128 append_history_to(&path, "real prompt");
129 append_history_to(&path, "/cost");
130 assert_eq!(load_history_from(&path), vec!["real prompt"]);
131 }
132
133 #[test]
134 fn empty_and_whitespace_skipped() {
135 let (_tmp, path) = temp_history_path();
136 append_history_to(&path, "");
137 append_history_to(&path, " ");
138 append_history_to(&path, "\n\t");
139 append_history_to(&path, "real");
140 assert_eq!(load_history_from(&path), vec!["real"]);
141 }
142
143 #[test]
144 fn consecutive_duplicates_deduped() {
145 let (_tmp, path) = temp_history_path();
146 append_history_to(&path, "same");
147 append_history_to(&path, "same");
148 append_history_to(&path, "same");
149 append_history_to(&path, "different");
150 append_history_to(&path, "same");
151 assert_eq!(load_history_from(&path), vec!["same", "different", "same"]);
152 }
153
154 #[test]
155 fn pruned_to_cap_at_append_time() {
156 let (_tmp, path) = temp_history_path();
157 for i in 0..(MAX_HISTORY_ENTRIES + 50) {
158 append_history_to(&path, &format!("entry {i}"));
159 }
160 let history = load_history_from(&path);
161 assert_eq!(history.len(), MAX_HISTORY_ENTRIES);
162 // Newest entries survive; oldest 50 were pruned.
163 assert_eq!(history.first().map(String::as_str), Some("entry 50"));
164 assert_eq!(
165 history.last().map(String::as_str),
166 Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref()
167 );
168 }
169
170 #[test]
171 fn missing_file_loads_empty() {
172 let (_tmp, path) = temp_history_path();
173 assert!(load_history_from(&path).is_empty());
174 }
175 }
176
176 lines RUST