Ctrl K
ComunidadGuías…

crates/tui/src/composer_history.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Cross-session composer input history (#366).
//!
//! Persists user-typed prompts to `~/.codewhale/composer_history.jsonl`
//! (using the legacy `~/.deepseek` root only when one already exists,
//! #3240) so pressing Up-arrow at the composer recalls
//! submissions from previous sessions, not just the current one. One entry
//! per JSON line, oldest first. Existing `composer_history.txt` files are
//! read verbatim on migration and left intact. The history is
//! capped at [`MAX_HISTORY_ENTRIES`] entries (older entries are pruned
//! at append time). Encoding every entry as a JSON string keeps multiline
//! prompts intact without mistaking old quoted prompts for encoded records.
//!
//! Slash commands are stored as well: recalling `/theme` or `/compact`
//! with Up-arrow is ordinary recall (#6006), and filtering on the `/`
//! prefix also dropped absolute paths like `cat /etc/fstab`. Empty /
//! whitespace-only inputs are still skipped.
//!
//! ## Off-thread writes (#1927)
//!
//! [`append_history`] used to block the caller for a read-then-atomic-
//! rewrite of the full file. That ran on the UI thread inside
//! `submit_input`, contributing a perceptible stall after Enter. The
//! public entry point now hands work to a dedicated writer thread via
//! [`writer_sender`] and returns immediately. Submissions stay serialised
//! in arrival order, so the on-disk file keeps its "oldest first"
//! invariant.

use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::time::Duration;

/// Hard cap on persisted history. Keeps the file small (typical entries
/// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load
/// time.
pub const MAX_HISTORY_ENTRIES: usize = 1000;

const HISTORY_FILE_NAME: &str = "composer_history.jsonl";
const LEGACY_HISTORY_FILE_NAME: &str = "composer_history.txt";

fn default_history_path() -> Option<PathBuf> {
    // Submitting a prompt in a UI test appends to the history; an unsealed test
    // must not write the developer's real `~/.codewhale/composer_history.jsonl`.
    #[cfg(test)]
    if let Some(home) = crate::test_support::unsealed_state_dir(".") {
        return history_path_with_home(Some(home));
    }
    if codewhale_paths::codewhale_home_is_explicit() {
        return codewhale_paths::codewhale_home()
            .ok()
            .flatten()
            .map(|home| home.join(HISTORY_FILE_NAME));
    }
    history_path_with_home(codewhale_paths::user_home())
}

/// Resolve the composer-history file under `home`, preferring the CodeWhale
/// root and only falling back to the legacy `.deepseek` root when a legacy
/// file already exists.
///
/// On a fresh install (neither file present) this returns the `.codewhale`
/// path, so the writer never recreates `~/.deepseek/` at runtime (#3240),
/// while users who haven't migrated keep their existing history in that
/// root. Mirrors the primary/legacy resolution used by
/// `snapshot::paths` and `artifacts`.
fn history_path_with_home(home: Option<PathBuf>) -> Option<PathBuf> {
    let home = home?;
    let primary = home.join(".codewhale").join(HISTORY_FILE_NAME);
    if primary.exists() || primary.with_file_name(LEGACY_HISTORY_FILE_NAME).exists() {
        return Some(primary);
    }
    let legacy = home.join(".deepseek").join(HISTORY_FILE_NAME);
    if legacy.exists() || legacy.with_file_name(LEGACY_HISTORY_FILE_NAME).exists() {
        return Some(legacy);
    }
    Some(primary)
}

/// Read the persisted history into memory. Returns an empty vec if the
/// file doesn't exist or can't be parsed — this is best-effort.
#[must_use]
pub fn load_history() -> Vec<String> {
    let Some(path) = default_history_path() else {
        return Vec::new();
    };
    load_history_from(&path)
}

fn load_history_from(path: &Path) -> Vec<String> {
    read_history_from(path).unwrap_or_else(|err| {
        tracing::warn!(
            "Failed to read composer history at {}: {err}",
            path.display()
        );
        Vec::new()
    })
}

fn read_history_from(path: &Path) -> std::io::Result<Vec<String>> {
    // A separate filename is the format discriminator: arbitrary legacy
    // prompts can themselves be valid JSON strings (including escapes).
    let (source, encoded) = if path.exists() {
        (path.to_path_buf(), true)
    } else {
        (path.with_file_name(LEGACY_HISTORY_FILE_NAME), false)
    };
    let file = match fs::File::open(source) {
        Ok(file) => file,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(err) => return Err(err),
    };
    let mut entries = Vec::new();
    for line in BufReader::new(file).lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        entries.push(if encoded {
            // Keep a malformed record as text rather than losing it on
            // the next append/rewrite.
            serde_json::from_str::<String>(&line).unwrap_or(line)
        } else {
            line
        });
    }
    Ok(entries)
}

/// Append an entry to the persisted history, pruning old entries to
/// stay within [`MAX_HISTORY_ENTRIES`]. Prompts and slash commands are kept;
/// empty input is skipped.
///
/// Best-effort and non-blocking — work is forwarded to a dedicated writer
/// thread so the caller (typically the UI submit handler) returns
/// immediately. See module docs for the rationale (#1927). Failures on
/// the writer thread are logged via `tracing` but not propagated.
pub fn append_history(entry: &str) {
    let Some(path) = default_history_path() else {
        return;
    };
    append_history_dispatched(&path, entry);
}

/// Path-injectable variant of [`append_history`] used by tests. Forwards
/// the work to the dedicated writer thread (or falls back to a synchronous
/// write if the channel send fails) so callers never block on disk I/O.
fn append_history_dispatched(path: &Path, entry: &str) {
    let entry = entry.to_string();
    if let Err(err) = writer_sender().send(HistoryWrite::Append(path.to_path_buf(), entry)) {
        match err.0 {
            HistoryWrite::Append(path, entry) => append_history_to(&path, &entry),
            #[cfg(test)]
            HistoryWrite::Flush(_) => unreachable!("flush messages are only sent by tests"),
        }
    }
}

enum HistoryWrite {
    Append(PathBuf, String),
    #[cfg(test)]
    Flush(Sender<()>),
}

/// Lazy singleton sender for the dedicated composer-history writer
/// thread. Initialised on first use; the thread runs for the lifetime
/// of the process and drains queued writes in arrival order.
fn writer_sender() -> &'static Sender<HistoryWrite> {
    static SENDER: OnceLock<Sender<HistoryWrite>> = OnceLock::new();
    SENDER.get_or_init(|| {
        let (tx, rx) = channel::<HistoryWrite>();
        let spawn_result = std::thread::Builder::new()
            .name("composer-history-writer".to_string())
            .spawn(move || {
                // recv() returns Err when all senders have dropped, which
                // only happens at process shutdown because the singleton
                // sender lives in a static for the lifetime of the process.
                while let Ok(message) = rx.recv() {
                    match message {
                        HistoryWrite::Append(path, entry) => {
                            append_history_batch(&rx, (path, entry));
                        }
                        #[cfg(test)]
                        HistoryWrite::Flush(done) => {
                            let _ = done.send(());
                        }
                    }
                }
            });
        if let Err(err) = spawn_result {
            tracing::warn!("Failed to spawn composer-history-writer: {err}");
        }
        tx
    })
}

fn append_history_batch(rx: &Receiver<HistoryWrite>, first: (PathBuf, String)) {
    let mut pending = vec![first];
    #[cfg(test)]
    let mut flush = None;

    loop {
        match rx.recv_timeout(Duration::from_millis(2)) {
            Ok(HistoryWrite::Append(path, entry)) => pending.push((path, entry)),
            #[cfg(test)]
            Ok(HistoryWrite::Flush(done)) => {
                flush = Some(done);
                break;
            }
            Err(RecvTimeoutError::Timeout) => break,
            Err(RecvTimeoutError::Disconnected) => break,
        }
    }

    for (path, entries) in group_history_writes_by_path(pending) {
        append_history_entries_to(&path, entries.iter().map(String::as_str));
    }

    #[cfg(test)]
    if let Some(done) = flush {
        let _ = done.send(());
    }
}

fn group_history_writes_by_path(writes: Vec<(PathBuf, String)>) -> Vec<(PathBuf, Vec<String>)> {
    let mut grouped: Vec<(PathBuf, Vec<String>)> = Vec::new();

    for (path, entry) in writes {
        if let Some((_, entries)) = grouped
            .iter_mut()
            .find(|(existing_path, _)| existing_path == &path)
        {
            entries.push(entry);
        } else {
            grouped.push((path, vec![entry]));
        }
    }

    grouped
}

fn append_history_to(path: &Path, entry: &str) {
    append_history_entries_to(path, std::iter::once(entry));
}

/// Keep immediate recall and persisted history on the same duplicate rule.
/// Callers choose whether to preserve the submitted whitespace in their copy.
pub(crate) fn push_history_entry(entries: &mut Vec<String>, entry: &str) -> bool {
    let trimmed = entry.trim();
    if trimmed.is_empty() || entries.last().is_some_and(|last| last.trim() == trimmed) {
        return false;
    }
    entries.push(entry.to_string());
    true
}

fn append_history_entries_to<'a>(
    path: &Path,
    entries_to_append: impl IntoIterator<Item = &'a str>,
) {
    if let Some(parent) = path.parent()
        && let Err(err) = fs::create_dir_all(parent)
    {
        tracing::warn!(
            "Failed to create composer history dir {}: {err}",
            parent.display()
        );
        return;
    }

    // Read existing entries, append the new ones, prune from the front
    // until under the cap, then atomically rewrite.
    let mut entries = match read_history_from(path) {
        Ok(entries) => entries,
        Err(err) => {
            tracing::warn!(
                "Not rewriting composer history at {}: reading it failed: {err}",
                path.display()
            );
            return;
        }
    };
    let mut changed = false;
    for entry in entries_to_append {
        changed |= push_history_entry(&mut entries, entry.trim());
    }

    if !changed {
        return;
    }

    if entries.len() > MAX_HISTORY_ENTRIES {
        let excess = entries.len() - MAX_HISTORY_ENTRIES;
        entries.drain(0..excess);
    }

    let payload = entries
        .iter()
        .map(|entry| serde_json::to_string(entry).expect("serializing a string cannot fail"))
        .collect::<Vec<_>>()
        .join("\n")
        + "\n";
    if let Err(err) = write_history_atomic(path, payload.as_bytes()) {
        tracing::warn!(
            "Failed to persist composer history at {}: {err}",
            path.display()
        );
    }
}

fn write_history_atomic(path: &Path, payload: &[u8]) -> std::io::Result<()> {
    const RETRY_DELAYS: &[Duration] = &[
        Duration::from_millis(5),
        Duration::from_millis(10),
        Duration::from_millis(25),
        Duration::from_millis(50),
        Duration::from_millis(100),
        Duration::from_millis(200),
        Duration::from_millis(400),
    ];

    for (attempt, delay) in RETRY_DELAYS
        .iter()
        .map(Some)
        .chain(std::iter::once(None))
        .enumerate()
    {
        match codewhale_util::utils::write_atomic(path, payload) {
            Ok(()) => return Ok(()),
            Err(err) if delay.is_some() => {
                tracing::debug!(
                    "Retrying composer history write to {} after attempt {} failed: {err}",
                    path.display(),
                    attempt + 1
                );
                std::thread::sleep(*delay.expect("delay checked"));
            }
            Err(err) => return Err(err),
        }
    }

    unreachable!("retry iterator always ends with a final write attempt")
}

#[cfg(test)]
pub(crate) fn flush_history_writer_for_tests(timeout: Duration) {
    let (done_tx, done_rx) = channel();
    writer_sender()
        .send(HistoryWrite::Flush(done_tx))
        .expect("history writer accepts flush");
    done_rx
        .recv_timeout(timeout)
        .expect("history writer flush timed out");
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{Duration, Instant};

    /// Tests use the path-injecting `*_from` / `*_to` helpers so they
    /// don't have to mutate `HOME` (which is not honored by
    /// `codewhale_paths::user_home()` on Windows — it reads `USERPROFILE` /
    /// `SHGetKnownFolderPath` instead). This makes the suite portable
    /// across all three CI runners without per-platform env juggling.
    fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
        let tmp = tempfile::tempdir().expect("tempdir");
        let path = tmp.path().join(HISTORY_FILE_NAME);
        (tmp, path)
    }

    // #3240: a fresh install must resolve the history file under `.codewhale`,
    // never the legacy `.deepseek` dir, so normal use doesn't recreate it.
    #[test]
    fn fresh_install_uses_codewhale_not_legacy() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let path = history_path_with_home(Some(tmp.path().to_path_buf()))
            .expect("path resolves with a home dir");
        assert_eq!(path, tmp.path().join(".codewhale").join(HISTORY_FILE_NAME));
        assert!(
            !path.starts_with(tmp.path().join(".deepseek")),
            "fresh install must not target the legacy .deepseek dir: {path:?}"
        );
    }

    // Migration care: an existing legacy text history keeps its root and
    // survives the first JSONL write byte-for-byte.
    #[test]
    fn existing_legacy_history_is_still_used() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let legacy = tmp.path().join(".deepseek").join(LEGACY_HISTORY_FILE_NAME);
        fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("mkdir legacy");
        fs::write(&legacy, "old entry\n").expect("seed legacy history");
        let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves");
        assert_eq!(path, legacy.with_file_name(HISTORY_FILE_NAME));
        assert_eq!(load_history_from(&path), ["old entry"]);
        append_history_to(&path, "new\nentry");
        assert_eq!(load_history_from(&path), ["old entry", "new\nentry"]);
        assert_eq!(fs::read_to_string(&legacy).unwrap(), "old entry\n");
    }

    // Once a `.codewhale` history exists it wins over either legacy format.
    #[test]
    fn codewhale_history_preferred_over_legacy() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let primary = tmp.path().join(".codewhale").join(LEGACY_HISTORY_FILE_NAME);
        let legacy = tmp.path().join(".deepseek").join(HISTORY_FILE_NAME);
        for p in [&primary, &legacy] {
            fs::create_dir_all(p.parent().expect("parent")).expect("mkdir");
            fs::write(p, "x\n").expect("seed");
        }
        let path = history_path_with_home(Some(tmp.path().to_path_buf())).expect("path resolves");
        assert_eq!(path, primary.with_file_name(HISTORY_FILE_NAME));
    }

    #[test]
    fn append_and_load_round_trip() {
        let (_tmp, path) = temp_history_path();
        append_history_to(&path, "first");
        append_history_to(&path, "second");
        append_history_to(&path, "third");
        assert_eq!(load_history_from(&path), vec!["first", "second", "third"]);
    }

    #[test]
    fn slash_commands_and_absolute_paths_stored() {
        let (_tmp, path) = temp_history_path();
        append_history_to(&path, "/help");
        append_history_to(&path, "real prompt");
        append_history_to(&path, "/cost");
        append_history_to(&path, "cat /etc/fstab");
        assert_eq!(
            load_history_from(&path),
            vec!["/help", "real prompt", "/cost", "cat /etc/fstab"]
        );
    }

    #[test]
    fn multi_line_entries_round_trip_as_one_entry() {
        let (_tmp, path) = temp_history_path();
        append_history_to(&path, "first");
        append_history_to(&path, "fn main() {\n    run();\n}");
        append_history_to(&path, "\"quoted\" start");
        append_history_to(&path, "last");
        assert_eq!(
            load_history_from(&path),
            vec![
                "first",
                "fn main() {\n    run();\n}",
                "\"quoted\" start",
                "last"
            ]
        );
        // The same multi-line prompt twice is still one consecutive duplicate.
        append_history_to(&path, "a\nb");
        append_history_to(&path, "a\nb");
        assert_eq!(load_history_from(&path).len(), 5);
    }

    #[test]
    fn legacy_plain_quoted_lines_keep_their_quotes() {
        let (_tmp, path) = temp_history_path();
        // Older writers trimmed entries and stored every remaining byte raw.
        // JSON-looking strings are user text, even when decoding and encoding
        // them would round-trip: the escapes must not turn into control chars.
        let legacy = [r#""yes""#, r#""a" and "b""#, r#""a\nb""#, r#""\"quoted\"""#];
        let legacy_path = path.with_file_name(LEGACY_HISTORY_FILE_NAME);
        let original = legacy.join("\n") + "\n";
        fs::write(&legacy_path, &original).expect("seed legacy file");
        assert_eq!(load_history_from(&path), legacy);
        // Rewriting on the next append must preserve every legacy prompt.
        append_history_to(&path, "next\nline");
        let expected: Vec<String> = legacy
            .into_iter()
            .chain(["next\nline"])
            .map(str::to_string)
            .collect();
        assert_eq!(load_history_from(&path), expected);
        assert_eq!(fs::read_to_string(&legacy_path).unwrap(), original);
    }

    #[test]
    fn malformed_json_records_stay_literal() {
        let (_tmp, path) = temp_history_path();
        fs::write(&path, "malformed record").expect("seed malformed record");
        assert_eq!(load_history_from(&path), ["malformed record"]);
        append_history_to(&path, "next");
        assert_eq!(load_history_from(&path), ["malformed record", "next"]);
    }

    #[test]
    fn consecutive_duplicate_commands_deduped() {
        let (_tmp, path) = temp_history_path();
        append_history_to(&path, "/theme");
        append_history_to(&path, "/theme");
        append_history_to(&path, "/theme");
        assert_eq!(load_history_from(&path), vec!["/theme"]);
    }

    #[test]
    fn empty_and_whitespace_skipped() {
        let (_tmp, path) = temp_history_path();
        append_history_to(&path, "");
        append_history_to(&path, "   ");
        append_history_to(&path, "\n\t");
        append_history_to(&path, "real");
        assert_eq!(load_history_from(&path), vec!["real"]);
    }

    #[test]
    fn consecutive_duplicates_deduped() {
        let (_tmp, path) = temp_history_path();
        append_history_to(&path, "same");
        append_history_to(&path, "same");
        append_history_to(&path, "same");
        append_history_to(&path, "different");
        append_history_to(&path, "same");
        assert_eq!(load_history_from(&path), vec!["same", "different", "same"]);
    }

    #[test]
    fn pruned_to_cap_at_append_time() {
        let (_tmp, path) = temp_history_path();
        // One batched rewrite — a per-entry loop would fsync 1000+ times.
        let entries: Vec<String> = (0..(MAX_HISTORY_ENTRIES + 50))
            .map(|i| format!("entry {i}"))
            .collect();
        append_history_entries_to(&path, entries.iter().map(String::as_str));
        let history = load_history_from(&path);
        assert_eq!(history.len(), MAX_HISTORY_ENTRIES);
        // Newest entries survive; oldest 50 were pruned.
        assert_eq!(history.first().map(String::as_str), Some("entry 50"));
        assert_eq!(
            history.last().map(String::as_str),
            Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref()
        );

        // Keep a cheap boundary check on the singleton production wrapper:
        // seed to one below the cap in one write, then cross it with only two
        // fsyncing appends. The second append must prune exactly the oldest
        // entry rather than only enforcing the cap for batched callers.
        let (_boundary_tmp, boundary_path) = temp_history_path();
        let seeded: Vec<String> = (0..(MAX_HISTORY_ENTRIES - 1))
            .map(|i| format!("entry {i}"))
            .collect();
        append_history_entries_to(&boundary_path, seeded.iter().map(String::as_str));
        append_history_to(
            &boundary_path,
            &format!("entry {}", MAX_HISTORY_ENTRIES - 1),
        );
        append_history_to(&boundary_path, &format!("entry {MAX_HISTORY_ENTRIES}"));
        let boundary = load_history_from(&boundary_path);
        assert_eq!(boundary.len(), MAX_HISTORY_ENTRIES);
        assert_eq!(boundary.first().map(String::as_str), Some("entry 1"));
        assert_eq!(
            boundary.last().map(String::as_str),
            Some(format!("entry {MAX_HISTORY_ENTRIES}")).as_deref()
        );
    }

    #[test]
    fn missing_file_loads_empty() {
        let (_tmp, path) = temp_history_path();
        assert!(load_history_from(&path).is_empty());
    }

    /// Regression for #1927 — the dispatched append path must return
    /// promptly even when a synchronous write of the seeded file would
    /// be slow. We pre-populate the file with ~1000 entries (the cap)
    /// so a sync read-modify-write would take real disk time on any
    /// platform, then call `append_history_dispatched` many times and
    /// assert that the cumulative wall-clock cost stays well below the
    /// stall the user reports.
    #[test]
    fn append_history_dispatched_does_not_block_the_caller() {
        let (_tmp, path) = temp_history_path();
        // Seed close to the cap so a synchronous rewrite is non-trivial.
        let seed = (0..(MAX_HISTORY_ENTRIES - 50))
            .map(|i| format!("seed entry {i}"))
            .collect::<Vec<_>>()
            .join("\n")
            + "\n";
        std::fs::write(&path, seed).expect("seed history");

        let start = Instant::now();
        for i in 0..50 {
            append_history_dispatched(&path, &format!("new entry {i}"));
        }
        let dispatch_elapsed = start.elapsed();

        // 50 sync read-modify-write cycles on a ~200KB file would be
        // measurable (tens of ms even on a fast SSD). The dispatch path
        // hands work to the writer thread and returns; the whole loop
        // should finish in single-digit ms. Pick a generous CI-safe
        // bound that still catches a regression to the old sync path.
        assert!(
            dispatch_elapsed < Duration::from_millis(150),
            "append_history dispatch was too slow: {dispatch_elapsed:?} \
             (likely re-introduced #1927: caller blocked on disk write)"
        );

        flush_history_writer_for_tests(Duration::from_secs(if cfg!(windows) { 10 } else { 5 }));

        let loaded = load_history_from(&path);
        assert!(
            loaded.iter().any(|line| line == "new entry 49"),
            "writer thread did not persist the dispatched entries; \
             loaded {} entries, last = {:?}",
            loaded.len(),
            loaded.last()
        );
        assert!(loaded.iter().any(|line| line == "new entry 0"));
    }
}