返回 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.jsonl`
4 //! (using the legacy `~/.deepseek` root only when one already exists,
5 //! #3240) so pressing Up-arrow at the composer recalls
6 //! submissions from previous sessions, not just the current one. One entry
7 //! per JSON line, oldest first. Existing `composer_history.txt` files are
8 //! read verbatim on migration and left intact. The history is
9 //! capped at [`MAX_HISTORY_ENTRIES`] entries (older entries are pruned
10 //! at append time). Encoding every entry as a JSON string keeps multiline
11 //! prompts intact without mistaking old quoted prompts for encoded records.
12 //!
13 //! Slash commands are stored as well: recalling `/theme` or `/compact`
14 //! with Up-arrow is ordinary recall (#6006), and filtering on the `/`
15 //! prefix also dropped absolute paths like `cat /etc/fstab`. Empty /
16 //! whitespace-only inputs are still skipped.
17 //!
18 //! ## Off-thread writes (#1927)
19 //!
20 //! [`append_history`] used to block the caller for a read-then-atomic-
21 //! rewrite of the full file. That ran on the UI thread inside
22 //! `submit_input`, contributing a perceptible stall after Enter. The
23 //! public entry point now hands work to a dedicated writer thread via
24 //! [`writer_sender`] and returns immediately. Submissions stay serialised
25 //! in arrival order, so the on-disk file keeps its "oldest first"
26 //! invariant.
27
28 use std::fs;
29 use std::io::{BufRead, BufReader};
30 use std::path::{Path, PathBuf};
31 use std::sync::OnceLock;
32 use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
33 use std::time::Duration;
34
35 /// Hard cap on persisted history. Keeps the file small (typical entries
36 /// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load
37 /// time.
38 pub const MAX_HISTORY_ENTRIES: usize = 1000;
39
40 const HISTORY_FILE_NAME: &str = "composer_history.jsonl";
41 const LEGACY_HISTORY_FILE_NAME: &str = "composer_history.txt";
42
43 fn default_history_path() -> Option<PathBuf> {
44 // Submitting a prompt in a UI test appends to the history; an unsealed test
45 // must not write the developer's real `~/.codewhale/composer_history.jsonl`.
46 #[cfg(test)]
47 if let Some(home) = crate::test_support::unsealed_state_dir(".") {
48 return history_path_with_home(Some(home));
49 }
50 history_path_with_home(crate::config::effective_home_dir())
51 }
52
53 /// Resolve the composer-history file under `home`, preferring the CodeWhale
54 /// root and only falling back to the legacy `.deepseek` root when a legacy
55 /// file already exists.
56 ///
57 /// On a fresh install (neither file present) this returns the `.codewhale`
58 /// path, so the writer never recreates `~/.deepseek/` at runtime (#3240),
59 /// while users who haven't migrated keep their existing history in that
60 /// root. Mirrors the primary/legacy resolution used by
61 /// `snapshot::paths` and `artifacts`.
62 fn history_path_with_home(home: Option<PathBuf>) -> Option<PathBuf> {
63 let home = home?;
64 let primary = home.join(".codewhale").join(HISTORY_FILE_NAME);
65 if primary.exists() || primary.with_file_name(LEGACY_HISTORY_FILE_NAME).exists() {
66 return Some(primary);
67 }
68 let legacy = home.join(".deepseek").join(HISTORY_FILE_NAME);
69 if legacy.exists() || legacy.with_file_name(LEGACY_HISTORY_FILE_NAME).exists() {
70 return Some(legacy);
71 }
72 Some(primary)
73 }
74
75 /// Read the persisted history into memory. Returns an empty vec if the
76 /// file doesn't exist or can't be parsed — this is best-effort.
77 #[must_use]
78 pub fn load_history() -> Vec<String> {
79 let Some(path) = default_history_path() else {
80 return Vec::new();
81 };
82 load_history_from(&path)
83 }
84
85 fn load_history_from(path: &Path) -> Vec<String> {
86 // A separate filename is the format discriminator: arbitrary legacy
87 // prompts can themselves be valid JSON strings (including escapes).
88 let (source, encoded) = if path.exists() {
89 (path.to_path_buf(), true)
90 } else {
91 (path.with_file_name(LEGACY_HISTORY_FILE_NAME), false)
92 };
93 let Ok(file) = fs::File::open(source) else {
94 return Vec::new();
95 };
96 BufReader::new(file)
97 .lines()
98 .map_while(Result::ok)
99 .filter(|line| !line.trim().is_empty())
100 .map(|line| {
101 if encoded {
102 // Keep a malformed record as text rather than losing it on
103 // the next append/rewrite.
104 serde_json::from_str::<String>(&line).unwrap_or(line)
105 } else {
106 line
107 }
108 })
109 .collect()
110 }
111
112 /// Append an entry to the persisted history, pruning old entries to
113 /// stay within [`MAX_HISTORY_ENTRIES`]. Prompts and slash commands are kept;
114 /// empty input is skipped.
115 ///
116 /// Best-effort and non-blocking — work is forwarded to a dedicated writer
117 /// thread so the caller (typically the UI submit handler) returns
118 /// immediately. See module docs for the rationale (#1927). Failures on
119 /// the writer thread are logged via `tracing` but not propagated.
120 pub fn append_history(entry: &str) {
121 let Some(path) = default_history_path() else {
122 return;
123 };
124 append_history_dispatched(&path, entry);
125 }
126
127 /// Path-injectable variant of [`append_history`] used by tests. Forwards
128 /// the work to the dedicated writer thread (or falls back to a synchronous
129 /// write if the channel send fails) so callers never block on disk I/O.
130 fn append_history_dispatched(path: &Path, entry: &str) {
131 let entry = entry.to_string();
132 if let Err(err) = writer_sender().send(HistoryWrite::Append(path.to_path_buf(), entry)) {
133 match err.0 {
134 HistoryWrite::Append(path, entry) => append_history_to(&path, &entry),
135 #[cfg(test)]
136 HistoryWrite::Flush(_) => unreachable!("flush messages are only sent by tests"),
137 }
138 }
139 }
140
141 enum HistoryWrite {
142 Append(PathBuf, String),
143 #[cfg(test)]
144 Flush(Sender<()>),
145 }
146
147 /// Lazy singleton sender for the dedicated composer-history writer
148 /// thread. Initialised on first use; the thread runs for the lifetime
149 /// of the process and drains queued writes in arrival order.
150 fn writer_sender() -> &'static Sender<HistoryWrite> {
151 static SENDER: OnceLock<Sender<HistoryWrite>> = OnceLock::new();
152 SENDER.get_or_init(|| {
153 let (tx, rx) = channel::<HistoryWrite>();
154 let spawn_result = std::thread::Builder::new()
155 .name("composer-history-writer".to_string())
156 .spawn(move || {
157 // recv() returns Err when all senders have dropped, which
158 // only happens at process shutdown because the singleton
159 // sender lives in a static for the lifetime of the process.
160 while let Ok(message) = rx.recv() {
161 match message {
162 HistoryWrite::Append(path, entry) => {
163 append_history_batch(&rx, (path, entry));
164 }
165 #[cfg(test)]
166 HistoryWrite::Flush(done) => {
167 let _ = done.send(());
168 }
169 }
170 }
171 });
172 if let Err(err) = spawn_result {
173 tracing::warn!("Failed to spawn composer-history-writer: {err}");
174 }
175 tx
176 })
177 }
178
179 fn append_history_batch(rx: &Receiver<HistoryWrite>, first: (PathBuf, String)) {
180 let mut pending = vec![first];
181 #[cfg(test)]
182 let mut flush = None;
183
184 loop {
185 match rx.recv_timeout(Duration::from_millis(2)) {
186 Ok(HistoryWrite::Append(path, entry)) => pending.push((path, entry)),
187 #[cfg(test)]
188 Ok(HistoryWrite::Flush(done)) => {
189 flush = Some(done);
190 break;
191 }
192 Err(RecvTimeoutError::Timeout) => break,
193 Err(RecvTimeoutError::Disconnected) => break,
194 }
195 }
196
197 for (path, entries) in group_history_writes_by_path(pending) {
198 append_history_entries_to(&path, entries.iter().map(String::as_str));
199 }
200
201 #[cfg(test)]
202 if let Some(done) = flush {
203 let _ = done.send(());
204 }
205 }
206
207 fn group_history_writes_by_path(writes: Vec<(PathBuf, String)>) -> Vec<(PathBuf, Vec<String>)> {
208 let mut grouped: Vec<(PathBuf, Vec<String>)> = Vec::new();
209
210 for (path, entry) in writes {
211 if let Some((_, entries)) = grouped
212 .iter_mut()
213 .find(|(existing_path, _)| existing_path == &path)
214 {
215 entries.push(entry);
216 } else {
217 grouped.push((path, vec![entry]));
218 }
219 }
220
221 grouped
222 }
223
224 fn append_history_to(path: &Path, entry: &str) {
225 append_history_entries_to(path, std::iter::once(entry));
226 }
227
228 /// Keep immediate recall and persisted history on the same duplicate rule.
229 /// Callers choose whether to preserve the submitted whitespace in their copy.
230 pub(crate) fn push_history_entry(entries: &mut Vec<String>, entry: &str) -> bool {
231 let trimmed = entry.trim();
232 if trimmed.is_empty() || entries.last().is_some_and(|last| last.trim() == trimmed) {
233 return false;
234 }
235 entries.push(entry.to_string());
236 true
237 }
238
239 fn append_history_entries_to<'a>(
240 path: &Path,
241 entries_to_append: impl IntoIterator<Item = &'a str>,
242 ) {
243 if let Some(parent) = path.parent()
244 && let Err(err) = fs::create_dir_all(parent)
245 {
246 tracing::warn!(
247 "Failed to create composer history dir {}: {err}",
248 parent.display()
249 );
250 return;
251 }
252
253 // Read existing entries, append the new ones, prune from the front
254 // until under the cap, then atomically rewrite.
255 let mut entries = load_history_from(path);
256 let mut changed = false;
257 for entry in entries_to_append {
258 changed |= push_history_entry(&mut entries, entry.trim());
259 }
260
261 if !changed {
262 return;
263 }
264
265 if entries.len() > MAX_HISTORY_ENTRIES {
266 let excess = entries.len() - MAX_HISTORY_ENTRIES;
267 entries.drain(0..excess);
268 }
269
270 let payload = entries
271 .iter()
272 .map(|entry| serde_json::to_string(entry).expect("serializing a string cannot fail"))
273 .collect::<Vec<_>>()
274 .join("\n")
275 + "\n";
276 if let Err(err) = write_history_atomic(path, payload.as_bytes()) {
277 tracing::warn!(
278 "Failed to persist composer history at {}: {err}",
279 path.display()
280 );
281 }
282 }
283
284 fn write_history_atomic(path: &Path, payload: &[u8]) -> std::io::Result<()> {
285 const RETRY_DELAYS: &[Duration] = &[
286 Duration::from_millis(5),
287 Duration::from_millis(10),
288 Duration::from_millis(25),
289 Duration::from_millis(50),
290 Duration::from_millis(100),
291 Duration::from_millis(200),
292 Duration::from_millis(400),
293 ];
294
295 for (attempt, delay) in RETRY_DELAYS
296 .iter()
297 .map(Some)
298 .chain(std::iter::once(None))
299 .enumerate()
300 {
301 match crate::utils::write_atomic(path, payload) {
302 Ok(()) => return Ok(()),
303 Err(err) if delay.is_some() => {
304 tracing::debug!(
305 "Retrying composer history write to {} after attempt {} failed: {err}",
306 path.display(),
307 attempt + 1
308 );
309 std::thread::sleep(*delay.expect("delay checked"));
310 }
311 Err(err) => return Err(err),
312 }
313 }
314
315 unreachable!("retry iterator always ends with a final write attempt")
316 }
317
318 #[cfg(test)]
319 pub(crate) fn flush_history_writer_for_tests(timeout: Duration) {
320 let (done_tx, done_rx) = channel();
321 writer_sender()
322 .send(HistoryWrite::Flush(done_tx))
323 .expect("history writer accepts flush");
324 done_rx
325 .recv_timeout(timeout)
326 .expect("history writer flush timed out");
327 }
328
329 #[cfg(test)]
330 mod tests {
331 use super::*;
332 use std::time::{Duration, Instant};
333
334 /// Tests use the path-injecting `*_from` / `*_to` helpers so they
335 /// don't have to mutate `HOME` (which is not honored by
336 /// `crate::config::effective_home_dir()` on Windows — it reads `USERPROFILE` /
337 /// `SHGetKnownFolderPath` instead). This makes the suite portable
338 /// across all three CI runners without per-platform env juggling.
339 fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
340 let tmp = tempfile::tempdir().expect("tempdir");
341 let path = tmp.path().join(HISTORY_FILE_NAME);
342 (tmp, path)
343 }
344
345 // #3240: a fresh install must resolve the history file under `.codewhale`,
346 // never the legacy `.deepseek` dir, so normal use doesn't recreate it.
347 #[test]
348 fn fresh_install_uses_codewhale_not_legacy() {
349 let tmp = tempfile::tempdir().expect("tempdir");
350 let path = history_path_with_home(Some(tmp.path().to_path_buf()))
351 .expect("path resolves with a home dir");
352 assert_eq!(path, tmp.path().join(".codewhale").join(HISTORY_FILE_NAME));
353 assert!(
354 !path.starts_with(tmp.path().join(".deepseek")),
355 "fresh install must not target the legacy .deepseek dir: {path:?}"
356 );
357 }
358
359 // Migration care: an existing legacy text history keeps its root and
360 // survives the first JSONL write byte-for-byte.
361 #[test]
362 fn existing_legacy_history_is_still_used() {
363 let tmp = tempfile::tempdir().expect("tempdir");
364 let legacy = tmp.path().join(".deepseek").join(LEGACY_HISTORY_FILE_NAME);
365 fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("mkdir legacy");
366 fs::write(&legacy, "old entry\n").expect("seed legacy history");
367 let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves");
368 assert_eq!(path, legacy.with_file_name(HISTORY_FILE_NAME));
369 assert_eq!(load_history_from(&path), ["old entry"]);
370 append_history_to(&path, "new\nentry");
371 assert_eq!(load_history_from(&path), ["old entry", "new\nentry"]);
372 assert_eq!(fs::read_to_string(&legacy).unwrap(), "old entry\n");
373 }
374
375 // Once a `.codewhale` history exists it wins over either legacy format.
376 #[test]
377 fn codewhale_history_preferred_over_legacy() {
378 let tmp = tempfile::tempdir().expect("tempdir");
379 let primary = tmp.path().join(".codewhale").join(LEGACY_HISTORY_FILE_NAME);
380 let legacy = tmp.path().join(".deepseek").join(HISTORY_FILE_NAME);
381 for p in [&primary, &legacy] {
382 fs::create_dir_all(p.parent().expect("parent")).expect("mkdir");
383 fs::write(p, "x\n").expect("seed");
384 }
385 let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves");
386 assert_eq!(path, primary.with_file_name(HISTORY_FILE_NAME));
387 }
388
389 #[test]
390 fn append_and_load_round_trip() {
391 let (_tmp, path) = temp_history_path();
392 append_history_to(&path, "first");
393 append_history_to(&path, "second");
394 append_history_to(&path, "third");
395 assert_eq!(load_history_from(&path), vec!["first", "second", "third"]);
396 }
397
398 #[test]
399 fn slash_commands_and_absolute_paths_stored() {
400 let (_tmp, path) = temp_history_path();
401 append_history_to(&path, "/help");
402 append_history_to(&path, "real prompt");
403 append_history_to(&path, "/cost");
404 append_history_to(&path, "cat /etc/fstab");
405 assert_eq!(
406 load_history_from(&path),
407 vec!["/help", "real prompt", "/cost", "cat /etc/fstab"]
408 );
409 }
410
411 #[test]
412 fn multi_line_entries_round_trip_as_one_entry() {
413 let (_tmp, path) = temp_history_path();
414 append_history_to(&path, "first");
415 append_history_to(&path, "fn main() {\n run();\n}");
416 append_history_to(&path, "\"quoted\" start");
417 append_history_to(&path, "last");
418 assert_eq!(
419 load_history_from(&path),
420 vec![
421 "first",
422 "fn main() {\n run();\n}",
423 "\"quoted\" start",
424 "last"
425 ]
426 );
427 // The same multi-line prompt twice is still one consecutive duplicate.
428 append_history_to(&path, "a\nb");
429 append_history_to(&path, "a\nb");
430 assert_eq!(load_history_from(&path).len(), 5);
431 }
432
433 #[test]
434 fn legacy_plain_quoted_lines_keep_their_quotes() {
435 let (_tmp, path) = temp_history_path();
436 // Older writers trimmed entries and stored every remaining byte raw.
437 // JSON-looking strings are user text, even when decoding and encoding
438 // them would round-trip: the escapes must not turn into control chars.
439 let legacy = [r#""yes""#, r#""a" and "b""#, r#""a\nb""#, r#""\"quoted\"""#];
440 let legacy_path = path.with_file_name(LEGACY_HISTORY_FILE_NAME);
441 let original = legacy.join("\n") + "\n";
442 fs::write(&legacy_path, &original).expect("seed legacy file");
443 assert_eq!(load_history_from(&path), legacy);
444 // Rewriting on the next append must preserve every legacy prompt.
445 append_history_to(&path, "next\nline");
446 let expected: Vec<String> = legacy
447 .into_iter()
448 .chain(["next\nline"])
449 .map(str::to_string)
450 .collect();
451 assert_eq!(load_history_from(&path), expected);
452 assert_eq!(fs::read_to_string(&legacy_path).unwrap(), original);
453 }
454
455 #[test]
456 fn malformed_json_records_stay_literal() {
457 let (_tmp, path) = temp_history_path();
458 fs::write(&path, "malformed record").expect("seed malformed record");
459 assert_eq!(load_history_from(&path), ["malformed record"]);
460 append_history_to(&path, "next");
461 assert_eq!(load_history_from(&path), ["malformed record", "next"]);
462 }
463
464 #[test]
465 fn consecutive_duplicate_commands_deduped() {
466 let (_tmp, path) = temp_history_path();
467 append_history_to(&path, "/theme");
468 append_history_to(&path, "/theme");
469 append_history_to(&path, "/theme");
470 assert_eq!(load_history_from(&path), vec!["/theme"]);
471 }
472
473 #[test]
474 fn empty_and_whitespace_skipped() {
475 let (_tmp, path) = temp_history_path();
476 append_history_to(&path, "");
477 append_history_to(&path, " ");
478 append_history_to(&path, "\n\t");
479 append_history_to(&path, "real");
480 assert_eq!(load_history_from(&path), vec!["real"]);
481 }
482
483 #[test]
484 fn consecutive_duplicates_deduped() {
485 let (_tmp, path) = temp_history_path();
486 append_history_to(&path, "same");
487 append_history_to(&path, "same");
488 append_history_to(&path, "same");
489 append_history_to(&path, "different");
490 append_history_to(&path, "same");
491 assert_eq!(load_history_from(&path), vec!["same", "different", "same"]);
492 }
493
494 #[test]
495 fn pruned_to_cap_at_append_time() {
496 let (_tmp, path) = temp_history_path();
497 // One batched rewrite — a per-entry loop would fsync 1000+ times.
498 let entries: Vec<String> = (0..(MAX_HISTORY_ENTRIES + 50))
499 .map(|i| format!("entry {i}"))
500 .collect();
501 append_history_entries_to(&path, entries.iter().map(String::as_str));
502 let history = load_history_from(&path);
503 assert_eq!(history.len(), MAX_HISTORY_ENTRIES);
504 // Newest entries survive; oldest 50 were pruned.
505 assert_eq!(history.first().map(String::as_str), Some("entry 50"));
506 assert_eq!(
507 history.last().map(String::as_str),
508 Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref()
509 );
510
511 // Keep a cheap boundary check on the singleton production wrapper:
512 // seed to one below the cap in one write, then cross it with only two
513 // fsyncing appends. The second append must prune exactly the oldest
514 // entry rather than only enforcing the cap for batched callers.
515 let (_boundary_tmp, boundary_path) = temp_history_path();
516 let seeded: Vec<String> = (0..(MAX_HISTORY_ENTRIES - 1))
517 .map(|i| format!("entry {i}"))
518 .collect();
519 append_history_entries_to(&boundary_path, seeded.iter().map(String::as_str));
520 append_history_to(
521 &boundary_path,
522 &format!("entry {}", MAX_HISTORY_ENTRIES - 1),
523 );
524 append_history_to(&boundary_path, &format!("entry {MAX_HISTORY_ENTRIES}"));
525 let boundary = load_history_from(&boundary_path);
526 assert_eq!(boundary.len(), MAX_HISTORY_ENTRIES);
527 assert_eq!(boundary.first().map(String::as_str), Some("entry 1"));
528 assert_eq!(
529 boundary.last().map(String::as_str),
530 Some(format!("entry {MAX_HISTORY_ENTRIES}")).as_deref()
531 );
532 }
533
534 #[test]
535 fn missing_file_loads_empty() {
536 let (_tmp, path) = temp_history_path();
537 assert!(load_history_from(&path).is_empty());
538 }
539
540 /// Regression for #1927 — the dispatched append path must return
541 /// promptly even when a synchronous write of the seeded file would
542 /// be slow. We pre-populate the file with ~1000 entries (the cap)
543 /// so a sync read-modify-write would take real disk time on any
544 /// platform, then call `append_history_dispatched` many times and
545 /// assert that the cumulative wall-clock cost stays well below the
546 /// stall the user reports.
547 #[test]
548 fn append_history_dispatched_does_not_block_the_caller() {
549 let (_tmp, path) = temp_history_path();
550 // Seed close to the cap so a synchronous rewrite is non-trivial.
551 let seed = (0..(MAX_HISTORY_ENTRIES - 50))
552 .map(|i| format!("seed entry {i}"))
553 .collect::<Vec<_>>()
554 .join("\n")
555 + "\n";
556 std::fs::write(&path, seed).expect("seed history");
557
558 let start = Instant::now();
559 for i in 0..50 {
560 append_history_dispatched(&path, &format!("new entry {i}"));
561 }
562 let dispatch_elapsed = start.elapsed();
563
564 // 50 sync read-modify-write cycles on a ~200KB file would be
565 // measurable (tens of ms even on a fast SSD). The dispatch path
566 // hands work to the writer thread and returns; the whole loop
567 // should finish in single-digit ms. Pick a generous CI-safe
568 // bound that still catches a regression to the old sync path.
569 assert!(
570 dispatch_elapsed < Duration::from_millis(150),
571 "append_history dispatch was too slow: {dispatch_elapsed:?} \
572 (likely re-introduced #1927: caller blocked on disk write)"
573 );
574
575 flush_history_writer_for_tests(Duration::from_secs(if cfg!(windows) { 10 } else { 5 }));
576
577 let loaded = load_history_from(&path);
578 assert!(
579 loaded.iter().any(|line| line == "new entry 49"),
580 "writer thread did not persist the dispatched entries; \
581 loaded {} entries, last = {:?}",
582 loaded.len(),
583 loaded.last()
584 );
585 assert!(loaded.iter().any(|line| line == "new entry 0"));
586 }
587 }
588
588 lines RUST