返回 CodeWhale
composer_history.rs
根目录 / crates / tui / src / composer_history.rs
1 //! Cross-session composer input history (#366).
2 //!
3 //! Persists user-typed prompts to `~/.codewhale/composer_history.txt`
4 //! (falling back to a legacy `~/.deepseek/composer_history.txt` only when
5 //! one already exists, #3240) so pressing Up-arrow at the composer recalls
6 //! submissions from previous sessions, not just the current one. One entry
7 //! per line, oldest first,
8 //! capped at [`MAX_HISTORY_ENTRIES`] entries (older entries are pruned
9 //! at append time).
10 //!
11 //! Entries that begin with `/` (slash commands) are NOT stored — they
12 //! pollute the recall stream and the fuzzy slash-menu already covers
13 //! them. Empty / whitespace-only inputs are also skipped.
14 //!
15 //! ## Off-thread writes (#1927)
16 //!
17 //! [`append_history`] used to block the caller for a read-then-atomic-
18 //! rewrite of the full file. That ran on the UI thread inside
19 //! `submit_input`, contributing a perceptible stall after Enter. The
20 //! public entry point now hands work to a dedicated writer thread via
21 //! [`writer_sender`] and returns immediately. Submissions stay serialised
22 //! in arrival order, so the on-disk file keeps its "oldest first"
23 //! invariant.
24
25 use std::fs;
26 use std::io::{BufRead, BufReader};
27 use std::path::{Path, PathBuf};
28 use std::sync::OnceLock;
29 use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
30 use std::time::Duration;
31
32 /// Hard cap on persisted history. Keeps the file small (typical entries
33 /// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load
34 /// time.
35 pub const MAX_HISTORY_ENTRIES: usize = 1000;
36
37 const HISTORY_FILE_NAME: &str = "composer_history.txt";
38
39 fn default_history_path() -> Option<PathBuf> {
40 history_path_with_home(crate::config::effective_home_dir())
41 }
42
43 /// Resolve the composer-history file under `home`, preferring the CodeWhale
44 /// root and only falling back to the legacy `.deepseek` root when a legacy
45 /// file already exists.
46 ///
47 /// On a fresh install (neither file present) this returns the `.codewhale`
48 /// path, so the writer never recreates `~/.deepseek/` at runtime (#3240),
49 /// while users who haven't migrated keep reading and appending to their
50 /// existing legacy history. Mirrors the primary/legacy resolution used by
51 /// `snapshot::paths` and `artifacts`.
52 fn history_path_with_home(home: Option<PathBuf>) -> Option<PathBuf> {
53 let home = home?;
54 let primary = home.join(".codewhale").join(HISTORY_FILE_NAME);
55 if primary.exists() {
56 return Some(primary);
57 }
58 let legacy = home.join(".deepseek").join(HISTORY_FILE_NAME);
59 if legacy.exists() {
60 return Some(legacy);
61 }
62 Some(primary)
63 }
64
65 /// Read the persisted history into memory. Returns an empty vec if the
66 /// file doesn't exist or can't be parsed — this is best-effort.
67 #[must_use]
68 pub fn load_history() -> Vec<String> {
69 let Some(path) = default_history_path() else {
70 return Vec::new();
71 };
72 load_history_from(&path)
73 }
74
75 fn load_history_from(path: &Path) -> Vec<String> {
76 let Ok(file) = fs::File::open(path) else {
77 return Vec::new();
78 };
79 BufReader::new(file)
80 .lines()
81 .map_while(Result::ok)
82 .filter(|line| !line.trim().is_empty())
83 .collect()
84 }
85
86 /// Append an entry to the persisted history, pruning old entries to
87 /// stay within [`MAX_HISTORY_ENTRIES`]. Slash-commands and empty input
88 /// are skipped — those don't help recall.
89 ///
90 /// Best-effort and non-blocking — work is forwarded to a dedicated writer
91 /// thread so the caller (typically the UI submit handler) returns
92 /// immediately. See module docs for the rationale (#1927). Failures on
93 /// the writer thread are logged via `tracing` but not propagated.
94 pub fn append_history(entry: &str) {
95 let Some(path) = default_history_path() else {
96 return;
97 };
98 append_history_dispatched(&path, entry);
99 }
100
101 /// Path-injectable variant of [`append_history`] used by tests. Forwards
102 /// the work to the dedicated writer thread (or falls back to a synchronous
103 /// write if the channel send fails) so callers never block on disk I/O.
104 fn append_history_dispatched(path: &Path, entry: &str) {
105 let entry = entry.to_string();
106 if let Err(err) = writer_sender().send(HistoryWrite::Append(path.to_path_buf(), entry)) {
107 match err.0 {
108 HistoryWrite::Append(path, entry) => append_history_to(&path, &entry),
109 #[cfg(test)]
110 HistoryWrite::Flush(_) => unreachable!("flush messages are only sent by tests"),
111 }
112 }
113 }
114
115 enum HistoryWrite {
116 Append(PathBuf, String),
117 #[cfg(test)]
118 Flush(Sender<()>),
119 }
120
121 /// Lazy singleton sender for the dedicated composer-history writer
122 /// thread. Initialised on first use; the thread runs for the lifetime
123 /// of the process and drains queued writes in arrival order.
124 fn writer_sender() -> &'static Sender<HistoryWrite> {
125 static SENDER: OnceLock<Sender<HistoryWrite>> = OnceLock::new();
126 SENDER.get_or_init(|| {
127 let (tx, rx) = channel::<HistoryWrite>();
128 let spawn_result = std::thread::Builder::new()
129 .name("composer-history-writer".to_string())
130 .spawn(move || {
131 // recv() returns Err when all senders have dropped, which
132 // only happens at process shutdown because the singleton
133 // sender lives in a static for the lifetime of the process.
134 while let Ok(message) = rx.recv() {
135 match message {
136 HistoryWrite::Append(path, entry) => {
137 append_history_batch(&rx, (path, entry));
138 }
139 #[cfg(test)]
140 HistoryWrite::Flush(done) => {
141 let _ = done.send(());
142 }
143 }
144 }
145 });
146 if let Err(err) = spawn_result {
147 tracing::warn!("Failed to spawn composer-history-writer: {err}");
148 }
149 tx
150 })
151 }
152
153 fn append_history_batch(rx: &Receiver<HistoryWrite>, first: (PathBuf, String)) {
154 let mut pending = vec![first];
155 #[cfg(test)]
156 let mut flush = None;
157
158 loop {
159 match rx.recv_timeout(Duration::from_millis(2)) {
160 Ok(HistoryWrite::Append(path, entry)) => pending.push((path, entry)),
161 #[cfg(test)]
162 Ok(HistoryWrite::Flush(done)) => {
163 flush = Some(done);
164 break;
165 }
166 Err(RecvTimeoutError::Timeout) => break,
167 Err(RecvTimeoutError::Disconnected) => break,
168 }
169 }
170
171 for (path, entries) in group_history_writes_by_path(pending) {
172 append_history_entries_to(&path, entries.iter().map(String::as_str));
173 }
174
175 #[cfg(test)]
176 if let Some(done) = flush {
177 let _ = done.send(());
178 }
179 }
180
181 fn group_history_writes_by_path(writes: Vec<(PathBuf, String)>) -> Vec<(PathBuf, Vec<String>)> {
182 let mut grouped: Vec<(PathBuf, Vec<String>)> = Vec::new();
183
184 for (path, entry) in writes {
185 if let Some((_, entries)) = grouped
186 .iter_mut()
187 .find(|(existing_path, _)| existing_path == &path)
188 {
189 entries.push(entry);
190 } else {
191 grouped.push((path, vec![entry]));
192 }
193 }
194
195 grouped
196 }
197
198 fn append_history_to(path: &Path, entry: &str) {
199 append_history_entries_to(path, std::iter::once(entry));
200 }
201
202 fn append_history_entries_to<'a>(
203 path: &Path,
204 entries_to_append: impl IntoIterator<Item = &'a str>,
205 ) {
206 if let Some(parent) = path.parent()
207 && let Err(err) = fs::create_dir_all(parent)
208 {
209 tracing::warn!(
210 "Failed to create composer history dir {}: {err}",
211 parent.display()
212 );
213 return;
214 }
215
216 // Read existing entries, append the new ones, prune from the front
217 // until under the cap, then atomically rewrite.
218 let mut entries = load_history_from(path);
219 let mut changed = false;
220 for entry in entries_to_append {
221 let trimmed = entry.trim();
222 if trimmed.is_empty() || trimmed.starts_with('/') {
223 continue;
224 }
225 if entries.last().map(String::as_str) == Some(trimmed) {
226 // De-dupe consecutive duplicates — repeated submission of the
227 // same prompt shouldn't bloat the file.
228 continue;
229 }
230 entries.push(trimmed.to_string());
231 changed = true;
232 }
233
234 if !changed {
235 return;
236 }
237
238 if entries.len() > MAX_HISTORY_ENTRIES {
239 let excess = entries.len() - MAX_HISTORY_ENTRIES;
240 entries.drain(0..excess);
241 }
242
243 let payload = entries.join("\n") + "\n";
244 if let Err(err) = write_history_atomic(path, payload.as_bytes()) {
245 tracing::warn!(
246 "Failed to persist composer history at {}: {err}",
247 path.display()
248 );
249 }
250 }
251
252 fn write_history_atomic(path: &Path, payload: &[u8]) -> std::io::Result<()> {
253 const RETRY_DELAYS: &[Duration] = &[
254 Duration::from_millis(5),
255 Duration::from_millis(10),
256 Duration::from_millis(25),
257 Duration::from_millis(50),
258 Duration::from_millis(100),
259 Duration::from_millis(200),
260 Duration::from_millis(400),
261 ];
262
263 for (attempt, delay) in RETRY_DELAYS
264 .iter()
265 .map(Some)
266 .chain(std::iter::once(None))
267 .enumerate()
268 {
269 match crate::utils::write_atomic(path, payload) {
270 Ok(()) => return Ok(()),
271 Err(err) if delay.is_some() => {
272 tracing::debug!(
273 "Retrying composer history write to {} after attempt {} failed: {err}",
274 path.display(),
275 attempt + 1
276 );
277 std::thread::sleep(*delay.expect("delay checked"));
278 }
279 Err(err) => return Err(err),
280 }
281 }
282
283 unreachable!("retry iterator always ends with a final write attempt")
284 }
285
286 #[cfg(test)]
287 mod tests {
288 use super::*;
289 use std::time::{Duration, Instant};
290
291 /// Tests use the path-injecting `*_from` / `*_to` helpers so they
292 /// don't have to mutate `HOME` (which is not honored by
293 /// `crate::config::effective_home_dir()` on Windows — it reads `USERPROFILE` /
294 /// `SHGetKnownFolderPath` instead). This makes the suite portable
295 /// across all three CI runners without per-platform env juggling.
296 fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
297 let tmp = tempfile::tempdir().expect("tempdir");
298 let path = tmp.path().join(HISTORY_FILE_NAME);
299 (tmp, path)
300 }
301
302 fn flush_history_writer_for_tests(timeout: Duration) {
303 let (done_tx, done_rx) = channel();
304 writer_sender()
305 .send(HistoryWrite::Flush(done_tx))
306 .expect("history writer accepts flush");
307 done_rx
308 .recv_timeout(timeout)
309 .expect("history writer flush timed out");
310 }
311
312 // #3240: a fresh install must resolve the history file under `.codewhale`,
313 // never the legacy `.deepseek` dir, so normal use doesn't recreate it.
314 #[test]
315 fn fresh_install_uses_codewhale_not_legacy() {
316 let tmp = tempfile::tempdir().expect("tempdir");
317 let path = history_path_with_home(Some(tmp.path().to_path_buf()))
318 .expect("path resolves with a home dir");
319 assert_eq!(path, tmp.path().join(".codewhale").join(HISTORY_FILE_NAME));
320 assert!(
321 !path.starts_with(tmp.path().join(".deepseek")),
322 "fresh install must not target the legacy .deepseek dir: {path:?}"
323 );
324 }
325
326 // Migration care: an existing legacy history is still read/appended.
327 #[test]
328 fn existing_legacy_history_is_still_used() {
329 let tmp = tempfile::tempdir().expect("tempdir");
330 let legacy = tmp.path().join(".deepseek").join(HISTORY_FILE_NAME);
331 fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("mkdir legacy");
332 fs::write(&legacy, "old entry\n").expect("seed legacy history");
333 let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves");
334 assert_eq!(path, legacy);
335 }
336
337 // Once a `.codewhale` history exists it wins over any legacy file.
338 #[test]
339 fn codewhale_history_preferred_over_legacy() {
340 let tmp = tempfile::tempdir().expect("tempdir");
341 let primary = tmp.path().join(".codewhale").join(HISTORY_FILE_NAME);
342 let legacy = tmp.path().join(".deepseek").join(HISTORY_FILE_NAME);
343 for p in [&primary, &legacy] {
344 fs::create_dir_all(p.parent().expect("parent")).expect("mkdir");
345 fs::write(p, "x\n").expect("seed");
346 }
347 let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves");
348 assert_eq!(path, primary);
349 }
350
351 #[test]
352 fn append_and_load_round_trip() {
353 let (_tmp, path) = temp_history_path();
354 append_history_to(&path, "first");
355 append_history_to(&path, "second");
356 append_history_to(&path, "third");
357 assert_eq!(load_history_from(&path), vec!["first", "second", "third"]);
358 }
359
360 #[test]
361 fn slash_commands_skipped() {
362 let (_tmp, path) = temp_history_path();
363 append_history_to(&path, "/help");
364 append_history_to(&path, "real prompt");
365 append_history_to(&path, "/cost");
366 assert_eq!(load_history_from(&path), vec!["real prompt"]);
367 }
368
369 #[test]
370 fn empty_and_whitespace_skipped() {
371 let (_tmp, path) = temp_history_path();
372 append_history_to(&path, "");
373 append_history_to(&path, " ");
374 append_history_to(&path, "\n\t");
375 append_history_to(&path, "real");
376 assert_eq!(load_history_from(&path), vec!["real"]);
377 }
378
379 #[test]
380 fn consecutive_duplicates_deduped() {
381 let (_tmp, path) = temp_history_path();
382 append_history_to(&path, "same");
383 append_history_to(&path, "same");
384 append_history_to(&path, "same");
385 append_history_to(&path, "different");
386 append_history_to(&path, "same");
387 assert_eq!(load_history_from(&path), vec!["same", "different", "same"]);
388 }
389
390 #[test]
391 fn pruned_to_cap_at_append_time() {
392 let (_tmp, path) = temp_history_path();
393 for i in 0..(MAX_HISTORY_ENTRIES + 50) {
394 append_history_to(&path, &format!("entry {i}"));
395 }
396 let history = load_history_from(&path);
397 assert_eq!(history.len(), MAX_HISTORY_ENTRIES);
398 // Newest entries survive; oldest 50 were pruned.
399 assert_eq!(history.first().map(String::as_str), Some("entry 50"));
400 assert_eq!(
401 history.last().map(String::as_str),
402 Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref()
403 );
404 }
405
406 #[test]
407 fn missing_file_loads_empty() {
408 let (_tmp, path) = temp_history_path();
409 assert!(load_history_from(&path).is_empty());
410 }
411
412 /// Regression for #1927 — the dispatched append path must return
413 /// promptly even when a synchronous write of the seeded file would
414 /// be slow. We pre-populate the file with ~1000 entries (the cap)
415 /// so a sync read-modify-write would take real disk time on any
416 /// platform, then call `append_history_dispatched` many times and
417 /// assert that the cumulative wall-clock cost stays well below the
418 /// stall the user reports.
419 #[test]
420 fn append_history_dispatched_does_not_block_the_caller() {
421 let (_tmp, path) = temp_history_path();
422 // Seed close to the cap so a synchronous rewrite is non-trivial.
423 let seed = (0..(MAX_HISTORY_ENTRIES - 50))
424 .map(|i| format!("seed entry {i}"))
425 .collect::<Vec<_>>()
426 .join("\n")
427 + "\n";
428 std::fs::write(&path, seed).expect("seed history");
429
430 let start = Instant::now();
431 for i in 0..50 {
432 append_history_dispatched(&path, &format!("new entry {i}"));
433 }
434 let dispatch_elapsed = start.elapsed();
435
436 // 50 sync read-modify-write cycles on a ~200KB file would be
437 // measurable (tens of ms even on a fast SSD). The dispatch path
438 // hands work to the writer thread and returns; the whole loop
439 // should finish in single-digit ms. Pick a generous CI-safe
440 // bound that still catches a regression to the old sync path.
441 assert!(
442 dispatch_elapsed < Duration::from_millis(150),
443 "append_history dispatch was too slow: {dispatch_elapsed:?} \
444 (likely re-introduced #1927: caller blocked on disk write)"
445 );
446
447 flush_history_writer_for_tests(Duration::from_secs(if cfg!(windows) { 10 } else { 5 }));
448
449 let loaded = load_history_from(&path);
450 assert!(
451 loaded.iter().any(|line| line == "new entry 49"),
452 "writer thread did not persist the dispatched entries; \
453 loaded {} entries, last = {:?}",
454 loaded.len(),
455 loaded.last()
456 );
457 assert!(loaded.iter().any(|line| line == "new entry 0"));
458 }
459 }
460
460 lines RUST