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