supercode-harness 0.5.45

The optional native Volter Harness agent and tool harness
Documentation
//! P5-4 (§3.1, D5 "cross-session prompt history"; S6: homed here per the
//! design's own §2 module-30 row — "Ctrl+R-style search is a
//! composer/UX affordance"): a persisted, searchable list of prompts the
//! user has submitted, surviving across TUI sessions (unlike an in-memory
//! `Vec` that resets on exit). Deliberately its own small file format (one
//! prompt per line, blank lines and `\n` collapsed to a literal `\n` escape
//! so a multi-line prompt round-trips as ONE history entry — NOT the same
//! file `rustyline`'s REPL history uses, since rustyline's `DefaultHistory`
//! serialization is a private implementation detail of that crate, not a
//! format this crate should parse) so a TUI session started days later
//! still has yesterday's prompts to Ctrl+R through.

use std::path::Path;

/// A persisted, searchable prompt history.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PromptHistory {
    /// Oldest first.
    entries: Vec<String>,
}

impl PromptHistory {
    /// An empty history (no file backing yet).
    pub fn new() -> Self {
        PromptHistory::default()
    }

    /// Load from `path`'s one-escaped-prompt-per-line format. A missing
    /// file (first run) or an unreadable one is treated as "empty history"
    /// rather than an error — losing prompt history is never worth
    /// refusing to start the TUI over.
    pub fn load_from_file(path: &Path) -> Self {
        let Ok(text) = std::fs::read_to_string(path) else {
            return PromptHistory::new();
        };
        let entries = text
            .lines()
            .filter(|l| !l.is_empty())
            .map(unescape_entry)
            .collect();
        PromptHistory { entries }
    }

    /// Persist to `path` (one escaped prompt per line, overwriting).
    /// Best-effort: a write failure (e.g. a read-only config dir) is
    /// silently dropped — the in-memory session history is unaffected
    /// either way, matching [`crate::permissions::ApprovalCache::approve`]'s
    /// "a lost write costs a feature, never a crash" precedent.
    pub fn save_to_file(&self, path: &Path) {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let text: String = self
            .entries
            .iter()
            .map(|e| escape_entry(e))
            .collect::<Vec<_>>()
            .join("\n");
        let _ = std::fs::write(path, text);
    }

    /// Append one submitted prompt. Adjacent-duplicate suppression (the
    /// same prompt submitted twice in a row doesn't grow the list) mirrors
    /// shell/readline history convention.
    pub fn push(&mut self, entry: impl Into<String>) {
        let entry = entry.into();
        if entry.is_empty() {
            return;
        }
        if self.entries.last().map(String::as_str) != Some(entry.as_str()) {
            self.entries.push(entry);
        }
    }

    /// How many entries this history holds.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether this history is empty.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// All entries the substring `query` (case-insensitive) appears in,
    /// MOST RECENT first (Ctrl+R convention: typing narrows toward the
    /// latest matching prompt). Empty `query` returns everything, most
    /// recent first.
    pub fn search(&self, query: &str) -> Vec<&str> {
        let q = query.to_ascii_lowercase();
        self.entries
            .iter()
            .rev()
            .filter(|e| q.is_empty() || e.to_ascii_lowercase().contains(&q))
            .map(String::as_str)
            .collect()
    }
}

fn escape_entry(s: &str) -> String {
    s.replace('\\', "\\\\").replace('\n', "\\n")
}

fn unescape_entry(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('n') => out.push('\n'),
                Some('\\') => out.push('\\'),
                Some(other) => {
                    out.push('\\');
                    out.push(other);
                }
                None => out.push('\\'),
            }
        } else {
            out.push(c);
        }
    }
    out
}