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}