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}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn push_then_search_finds_substring_most_recent_first() {
131        let mut h = PromptHistory::new();
132        h.push("fix the login bug");
133        h.push("add a test for login");
134        h.push("refactor the parser");
135        let hits = h.search("login");
136        assert_eq!(hits, vec!["add a test for login", "fix the login bug"]);
137    }
138
139    #[test]
140    fn search_empty_query_returns_all_most_recent_first() {
141        let mut h = PromptHistory::new();
142        h.push("one");
143        h.push("two");
144        assert_eq!(h.search(""), vec!["two", "one"]);
145    }
146
147    #[test]
148    fn adjacent_duplicate_is_not_appended_twice() {
149        let mut h = PromptHistory::new();
150        h.push("same");
151        h.push("same");
152        assert_eq!(h.len(), 1);
153    }
154
155    #[test]
156    fn empty_push_is_ignored() {
157        let mut h = PromptHistory::new();
158        h.push("");
159        assert!(h.is_empty());
160    }
161
162    #[test]
163    fn round_trips_through_a_file_including_multiline_entries() {
164        let dir = std::env::temp_dir().join(format!(
165            "supercode-tui-history-test-{}-{}",
166            std::process::id(),
167            std::time::SystemTime::now()
168                .duration_since(std::time::UNIX_EPOCH)
169                .unwrap()
170                .as_nanos()
171        ));
172        let path = dir.join("history.txt");
173        let mut h = PromptHistory::new();
174        h.push("single line");
175        h.push("multi\nline\nprompt");
176        h.push("with a \\ backslash");
177        h.save_to_file(&path);
178        let loaded = PromptHistory::load_from_file(&path);
179        assert_eq!(loaded, h);
180        let _ = std::fs::remove_dir_all(&dir);
181    }
182
183    #[test]
184    fn load_from_missing_file_is_empty_not_an_error() {
185        let path = std::env::temp_dir().join("supercode-tui-history-definitely-missing.txt");
186        let _ = std::fs::remove_file(&path);
187        let h = PromptHistory::load_from_file(&path);
188        assert!(h.is_empty());
189    }
190}