| 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 |