Skip to main content

supercode_harness/tui/
history.rs

1//! P5-4 (§3.1, D5 "cross-session prompt history"; S6: homed here per the
2//! design's own §2 module-30 row — "Ctrl+R-style search is a
3//! composer/UX affordance"): a persisted, searchable list of prompts the
4//! user has submitted, surviving across TUI sessions (unlike an in-memory
5//! `Vec` that resets on exit). Deliberately its own small file format (one
6//! prompt per line, blank lines and `\n` collapsed to a literal `\n` escape
7//! so a multi-line prompt round-trips as ONE history entry — NOT the same
8//! file `rustyline`'s REPL history uses, since rustyline's `DefaultHistory`
9//! serialization is a private implementation detail of that crate, not a
10//! format this crate should parse) so a TUI session started days later
11//! still has yesterday's prompts to Ctrl+R through.
12
13use std::path::Path;
14
15/// A persisted, searchable prompt history.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct PromptHistory {
18    /// Oldest first.
19    entries: Vec<String>,
20}
21
22impl PromptHistory {
23    /// An empty history (no file backing yet).
24    pub fn new() -> Self {
25        PromptHistory::default()
26    }
27
28    /// Load from `path`'s one-escaped-prompt-per-line format. A missing
29    /// file (first run) or an unreadable one is treated as "empty history"
30    /// rather than an error — losing prompt history is never worth
31    /// refusing to start the TUI over.
32    pub fn load_from_file(path: &Path) -> Self {
33        let Ok(text) = std::fs::read_to_string(path) else {
34            return PromptHistory::new();
35        };
36        let entries = text
37            .lines()
38            .filter(|l| !l.is_empty())
39            .map(unescape_entry)
40            .collect();
41        PromptHistory { entries }
42    }
43
44    /// Persist to `path` (one escaped prompt per line, overwriting).
45    /// Best-effort: a write failure (e.g. a read-only config dir) is
46    /// silently dropped — the in-memory session history is unaffected
47    /// either way, matching [`crate::permissions::ApprovalCache::approve`]'s
48    /// "a lost write costs a feature, never a crash" precedent.
49    pub fn save_to_file(&self, path: &Path) {
50        if let Some(parent) = path.parent() {
51            let _ = std::fs::create_dir_all(parent);
52        }
53        let text: String = self
54            .entries
55            .iter()
56            .map(|e| escape_entry(e))
57            .collect::<Vec<_>>()
58            .join("\n");
59        let _ = std::fs::write(path, text);
60    }
61
62    /// Append one submitted prompt. Adjacent-duplicate suppression (the
63    /// same prompt submitted twice in a row doesn't grow the list) mirrors
64    /// shell/readline history convention.
65    pub fn push(&mut self, entry: impl Into<String>) {
66        let entry = entry.into();
67        if entry.is_empty() {
68            return;
69        }
70        if self.entries.last().map(String::as_str) != Some(entry.as_str()) {
71            self.entries.push(entry);
72        }
73    }
74
75    /// How many entries this history holds.
76    pub fn len(&self) -> usize {
77        self.entries.len()
78    }
79
80    /// Whether this history is empty.
81    pub fn is_empty(&self) -> bool {
82        self.entries.is_empty()
83    }
84
85    /// All entries the substring `query` (case-insensitive) appears in,
86    /// MOST RECENT first (Ctrl+R convention: typing narrows toward the
87    /// latest matching prompt). Empty `query` returns everything, most
88    /// recent first.
89    pub fn search(&self, query: &str) -> Vec<&str> {
90        let q = query.to_ascii_lowercase();
91        self.entries
92            .iter()
93            .rev()
94            .filter(|e| q.is_empty() || e.to_ascii_lowercase().contains(&q))
95            .map(String::as_str)
96            .collect()
97    }
98}
99
100fn escape_entry(s: &str) -> String {
101    s.replace('\\', "\\\\").replace('\n', "\\n")
102}
103
104fn unescape_entry(s: &str) -> String {
105    let mut out = String::with_capacity(s.len());
106    let mut chars = s.chars();
107    while let Some(c) = chars.next() {
108        if c == '\\' {
109            match chars.next() {
110                Some('n') => out.push('\n'),
111                Some('\\') => out.push('\\'),
112                Some(other) => {
113                    out.push('\\');
114                    out.push(other);
115                }
116                None => out.push('\\'),
117            }
118        } else {
119            out.push(c);
120        }
121    }
122    out
123}