diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 9e105e3a..4788b07f 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::path::Path; use std::time::{Duration, Instant}; use protocol::{ @@ -13,6 +14,9 @@ use crate::cache::FileCache; use crate::command::{ CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry, }; +use crate::composer_history::{ + COMPOSER_INPUT_HISTORY_LIMIT, ComposerHistoryStore, segments_are_blank, +}; use crate::input::InputBuffer; use crate::scroll::Scroll; use crate::task::TaskStore; @@ -107,8 +111,6 @@ impl QueuedInput { } } -const COMPOSER_INPUT_HISTORY_LIMIT: usize = 100; - struct ComposerInputHistory { entries: VecDeque>, browse: Option, @@ -127,18 +129,26 @@ impl ComposerInputHistory { } } - fn record(&mut self, segments: Vec) { + fn with_entries(entries: VecDeque>) -> Self { + Self { + entries, + browse: None, + } + } + + fn record(&mut self, segments: Vec) -> bool { if segments_are_blank(&segments) { - return; + return false; } self.browse = None; if self.entries.back() == Some(&segments) { - return; + return false; } if self.entries.len() == COMPOSER_INPUT_HISTORY_LIMIT { self.entries.pop_front(); } self.entries.push_back(segments); + true } fn is_browsing(&self) -> bool { @@ -283,6 +293,9 @@ pub struct App { /// TUI-local readline-style composer input history. This is intentionally /// client-side only: recalled entries are plain drafts until submitted again. input_history: ComposerInputHistory, + /// User-data backed persistence for composer recall entries. The saved + /// contents are private input drafts and must not be logged or sent to Pod. + input_history_store: Option, /// Local submit state kept until the accepted run either completes /// normally or reports that the empty assistant turn was rolled back. pending_submit_rollback: Option, @@ -330,11 +343,72 @@ impl App { task_pane_scroll: 0, queued_inputs: VecDeque::new(), input_history: ComposerInputHistory::new(), + input_history_store: None, pending_submit_rollback: None, last_rolled_back_input: None, } } + pub fn new_with_persistent_input_history(pod_name: String, workspace_root: &Path) -> Self { + let mut app = Self::new(pod_name); + match ComposerHistoryStore::default_for_workspace(workspace_root) { + Ok(Some(store)) => { + match store.load() { + Ok(entries) => { + app.input_history = ComposerInputHistory::with_entries(entries); + } + Err(_) => { + app.flash_actionbar_notice( + "Could not load saved composer input history; starting with empty local history.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + Duration::from_secs(8), + ); + } + } + app.input_history_store = Some(store); + } + Ok(None) => { + app.flash_actionbar_notice( + "Composer input history persistence is disabled because the yoi data directory could not be resolved.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + Duration::from_secs(8), + ); + } + Err(_) => { + app.flash_actionbar_notice( + "Composer input history persistence is disabled because the history store could not be initialized.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + Duration::from_secs(8), + ); + } + } + app + } + + #[cfg(test)] + fn new_with_input_history_store(pod_name: String, store: ComposerHistoryStore) -> Self { + let mut app = Self::new(pod_name); + match store.load() { + Ok(entries) => { + app.input_history = ComposerInputHistory::with_entries(entries); + } + Err(_) => { + app.flash_actionbar_notice_at( + "Could not load saved composer input history; starting with empty local history.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + Instant::now(), + Duration::from_secs(8), + ); + } + } + app.input_history_store = Some(store); + app + } + pub fn toggle_task_pane(&mut self) { self.task_pane_open = !self.task_pane_open; if !self.task_pane_open { @@ -556,7 +630,7 @@ impl App { } return None; } - self.input_history.record(segments.clone()); + self.record_input_history(segments.clone()); if self.running { self.queued_inputs.push_back(QueuedInput::new(segments)); self.input.clear(); @@ -582,6 +656,23 @@ impl App { Method::Run { input: segments } } + fn record_input_history(&mut self, segments: Vec) { + if !self.input_history.record(segments) { + return; + } + let Some(store) = &self.input_history_store else { + return; + }; + if store.save(&self.input_history.entries).is_err() { + self.flash_actionbar_notice( + "Could not save composer input history; continuing with in-memory local history.", + ActionbarNoticeLevel::Warn, + ActionbarNoticeSource::Tui, + Duration::from_secs(8), + ); + } + } + pub fn queued_input_count(&self) -> usize { self.queued_inputs.len() } @@ -1934,17 +2025,6 @@ fn rollback_input_preview(text: &str) -> String { one_line } -/// True if the submitted segment list carries no user-visible content -/// (only whitespace / newlines, no paste, no typed atoms). Used to -/// decide whether an empty Enter should be a no-op or trigger a -/// `Resume` when the Pod is paused. -fn segments_are_blank(segments: &[Segment]) -> bool { - segments.iter().all(|s| match s { - Segment::Text { content } => content.trim().is_empty(), - _ => false, - }) -} - pub fn alert_source_label(source: AlertSource) -> &'static str { match source { AlertSource::Pod => "pod", @@ -2029,6 +2109,146 @@ mod actionbar_notice_tests { } } +#[cfg(test)] +mod composer_history_persistence_tests { + use super::*; + use crate::composer_history::{COMPOSER_INPUT_HISTORY_LIMIT, ComposerHistoryStore}; + use std::fs; + use tempfile::TempDir; + + #[test] + fn recall_history_survives_reload_for_same_workspace() { + let data_dir = TempDir::new().unwrap(); + let workspace = TempDir::new().unwrap(); + let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path()); + let mut app = App::new_with_input_history_store("test".into(), store.clone()); + submit_text(&mut app, "first synthetic entry"); + submit_text(&mut app, "second synthetic entry"); + + let mut reloaded = App::new_with_input_history_store("test".into(), store); + assert!(reloaded.browse_input_history_older()); + assert_eq!(input_text(&reloaded), "second synthetic entry"); + assert!(reloaded.browse_input_history_older()); + assert_eq!(input_text(&reloaded), "first synthetic entry"); + } + + #[test] + fn recall_histories_are_separated_by_workspace() { + let data_dir = TempDir::new().unwrap(); + let workspace_a = TempDir::new().unwrap(); + let workspace_b = TempDir::new().unwrap(); + let store_a = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace_a.path()); + let store_b = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace_b.path()); + let mut app_a = App::new_with_input_history_store("test".into(), store_a.clone()); + let mut app_b = App::new_with_input_history_store("test".into(), store_b.clone()); + + submit_text(&mut app_a, "workspace a synthetic entry"); + submit_text(&mut app_b, "workspace b synthetic entry"); + + let mut reloaded_a = App::new_with_input_history_store("test".into(), store_a); + let mut reloaded_b = App::new_with_input_history_store("test".into(), store_b); + assert!(reloaded_a.browse_input_history_older()); + assert!(reloaded_b.browse_input_history_older()); + assert_eq!(input_text(&reloaded_a), "workspace a synthetic entry"); + assert_eq!(input_text(&reloaded_b), "workspace b synthetic entry"); + } + + #[test] + fn persistence_keeps_typed_segments_instead_of_flattening() { + let data_dir = TempDir::new().unwrap(); + let workspace = TempDir::new().unwrap(); + let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path()); + let mut app = App::new_with_input_history_store("test".into(), store.clone()); + app.input.replace_with_segments(&[ + Segment::text("inspect "), + Segment::FileRef { + path: "src/lib.rs".into(), + }, + ]); + assert!(matches!(app.submit_input(), Some(Method::Run { .. }))); + + let mut reloaded = App::new_with_input_history_store("test".into(), store); + assert!(reloaded.browse_input_history_older()); + let segments = reloaded.input.submit_segments(); + assert_eq!(segments.len(), 2); + assert!(matches!(&segments[0], Segment::Text { content } if content == "inspect ")); + assert!(matches!(&segments[1], Segment::FileRef { path } if path == "src/lib.rs")); + } + + #[test] + fn persistence_bounds_history_and_suppresses_consecutive_duplicates() { + let data_dir = TempDir::new().unwrap(); + let workspace = TempDir::new().unwrap(); + let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path()); + let mut app = App::new_with_input_history_store("test".into(), store.clone()); + submit_text(&mut app, "duplicate synthetic entry"); + submit_text(&mut app, "duplicate synthetic entry"); + for i in 0..COMPOSER_INPUT_HISTORY_LIMIT + 5 { + submit_text(&mut app, &format!("bounded synthetic entry {i}")); + } + + let reloaded = App::new_with_input_history_store("test".into(), store); + assert_eq!( + reloaded.input_history.entries.len(), + COMPOSER_INPUT_HISTORY_LIMIT + ); + assert_eq!( + reloaded.input_history.entries.front(), + Some(&vec![Segment::text("bounded synthetic entry 5")]) + ); + assert_eq!( + reloaded.input_history.entries.back(), + Some(&vec![Segment::text("bounded synthetic entry 34")]) + ); + } + + #[test] + fn corrupt_history_file_falls_back_to_empty_with_bounded_warning() { + let data_dir = TempDir::new().unwrap(); + let workspace = TempDir::new().unwrap(); + let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path()); + fs::create_dir_all(store.path().parent().unwrap()).unwrap(); + fs::write(store.path(), b"not json").unwrap(); + + let app = App::new_with_input_history_store("test".into(), store); + assert_eq!(app.input_history.entries.len(), 0); + let notice = app.current_actionbar_notice(Instant::now()).unwrap(); + assert_eq!(notice.level, ActionbarNoticeLevel::Warn); + assert!( + notice + .text + .contains("Could not load saved composer input history") + ); + assert!(!notice.text.contains("not json")); + } + + #[test] + fn persistence_does_not_write_workspace_yoi_directory() { + let data_dir = TempDir::new().unwrap(); + let workspace = TempDir::new().unwrap(); + let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path()); + let mut app = App::new_with_input_history_store("test".into(), store); + submit_text(&mut app, "synthetic entry outside workspace yoi"); + + assert!(data_dir.path().join("composer-history").exists()); + assert!(!workspace.path().join(".yoi").exists()); + } + + fn submit_text(app: &mut App, text: &str) -> Vec { + for c in text.chars() { + app.insert_char(c); + } + match app.submit_input() { + Some(Method::Run { input }) => input, + other => panic!("expected Run, got {other:?}"), + } + } + + fn input_text(app: &App) -> String { + Segment::flatten_to_text(&app.input.submit_segments()) + } +} + #[cfg(test)] mod completion_flow_tests { use super::*; diff --git a/crates/tui/src/composer_history.rs b/crates/tui/src/composer_history.rs new file mode 100644 index 00000000..0e221e47 --- /dev/null +++ b/crates/tui/src/composer_history.rs @@ -0,0 +1,240 @@ +use std::collections::VecDeque; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use protocol::Segment; +use serde::{Deserialize, Serialize}; + +pub(crate) const COMPOSER_INPUT_HISTORY_LIMIT: usize = 30; +const COMPOSER_HISTORY_VERSION: u32 = 1; + +#[derive(Debug, Clone)] +pub(crate) struct ComposerHistoryStore { + path: PathBuf, + workspace: WorkspaceIdentity, +} + +#[derive(Debug)] +pub(crate) enum ComposerHistoryLoadError { + Io(io::Error), + Json(serde_json::Error), +} + +impl std::fmt::Display for ComposerHistoryLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(f, "{error}"), + Self::Json(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for ComposerHistoryLoadError {} + +impl From for ComposerHistoryLoadError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for ComposerHistoryLoadError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ComposerHistoryFile { + version: u32, + workspace: WorkspaceIdentity, + entries: Vec>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WorkspaceIdentity { + root: String, + label: String, + key: String, +} + +impl ComposerHistoryStore { + pub(crate) fn default_for_workspace(workspace_root: &Path) -> io::Result> { + let Some(data_dir) = manifest::paths::data_dir() else { + return Ok(None); + }; + Ok(Some(Self::for_data_dir(data_dir, workspace_root))) + } + + pub(crate) fn for_data_dir(data_dir: impl AsRef, workspace_root: &Path) -> Self { + let workspace = workspace_identity(workspace_root); + let path = data_dir + .as_ref() + .join("composer-history") + .join("workspaces") + .join(format!("{}-{}", workspace.label, workspace.key)) + .join("history.json"); + Self { path, workspace } + } + + #[cfg(test)] + pub(crate) fn path(&self) -> &Path { + &self.path + } + + pub(crate) fn load(&self) -> Result>, ComposerHistoryLoadError> { + let bytes = match fs::read(&self.path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(VecDeque::new()), + Err(error) => return Err(error.into()), + }; + let persisted: ComposerHistoryFile = serde_json::from_slice(&bytes)?; + Ok(normalized_entries(persisted.entries)) + } + + pub(crate) fn save( + &self, + entries: &VecDeque>, + ) -> Result<(), ComposerHistoryLoadError> { + let persisted = ComposerHistoryFile { + version: COMPOSER_HISTORY_VERSION, + workspace: self.workspace.clone(), + entries: entries.iter().cloned().collect(), + }; + let bytes = serde_json::to_vec_pretty(&persisted)?; + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + let tmp = self.path.with_extension("json.tmp"); + fs::write(&tmp, bytes)?; + fs::rename(tmp, &self.path)?; + Ok(()) + } +} + +pub(crate) fn normalized_entries(entries: Vec>) -> VecDeque> { + let mut normalized = VecDeque::new(); + for segments in entries { + if segments_are_blank(&segments) { + continue; + } + if normalized.back() == Some(&segments) { + continue; + } + if normalized.len() == COMPOSER_INPUT_HISTORY_LIMIT { + normalized.pop_front(); + } + normalized.push_back(segments); + } + normalized +} + +pub(crate) fn segments_are_blank(segments: &[Segment]) -> bool { + segments.iter().all(|s| match s { + Segment::Text { content } => content.trim().is_empty(), + _ => false, + }) +} + +fn workspace_identity(workspace_root: &Path) -> WorkspaceIdentity { + let root = normalized_workspace_key(workspace_root); + let label = workspace_leaf(&root); + let key = fnv1a64_hex(root.as_bytes()); + WorkspaceIdentity { root, label, key } +} + +fn normalized_workspace_key(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn workspace_leaf(workspace_root: &str) -> String { + let leaf = workspace_root + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or("workspace"); + let sanitized = leaf + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + if sanitized.is_empty() { + "workspace".to_string() + } else { + sanitized + } +} + +fn fnv1a64_hex(bytes: &[u8]) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn store_path_is_workspace_scoped_under_data_dir() { + let data_dir = TempDir::new().unwrap(); + let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi")); + let other = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/other")); + + assert!(store.path().starts_with(data_dir.path())); + assert!( + store + .path() + .to_string_lossy() + .contains("composer-history/workspaces/yoi-") + ); + assert_ne!(store.path(), other.path()); + } + + #[test] + fn file_records_workspace_identity_metadata_and_typed_segments() { + let data_dir = TempDir::new().unwrap(); + let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi")); + let entries = VecDeque::from([vec![ + Segment::text("see "), + Segment::FileRef { + path: "src/main.rs".into(), + }, + ]]); + + store.save(&entries).unwrap(); + let raw: serde_json::Value = + serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap(); + assert_eq!(raw["workspace"]["root"], "/repo/yoi"); + assert_eq!(raw["workspace"]["label"], "yoi"); + assert!(raw["workspace"]["key"].as_str().unwrap().len() >= 16); + assert_eq!(raw["entries"][0][1]["kind"], "file_ref"); + assert_eq!(store.load().unwrap(), entries); + } + + #[test] + fn normalization_bounds_blanks_and_consecutive_duplicates() { + let mut entries = Vec::new(); + entries.push(vec![Segment::text(" ")]); + entries.push(vec![Segment::text("same")]); + entries.push(vec![Segment::text("same")]); + for i in 0..COMPOSER_INPUT_HISTORY_LIMIT + 5 { + entries.push(vec![Segment::text(format!("entry-{i}"))]); + } + + let normalized = normalized_entries(entries); + assert_eq!(normalized.len(), COMPOSER_INPUT_HISTORY_LIMIT); + assert_eq!(normalized.front(), Some(&vec![Segment::text("entry-5")])); + assert_eq!(normalized.back(), Some(&vec![Segment::text("entry-34")])); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 8dfd8c53..75d28cd7 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -2,6 +2,7 @@ mod app; mod block; mod cache; mod command; +mod composer_history; mod input; pub mod keys; mod markdown; diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index f90f3f21..2f6f5264 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -69,7 +69,8 @@ async fn run_connected_pod( client: PodClient, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { - let mut app = App::new(pod_name); + let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let mut app = App::new_with_persistent_input_history(pod_name, &workspace_root); app.connected = true; run_loop(terminal, &mut app, client, runtime_command).await } @@ -273,7 +274,8 @@ async fn run( socket_path: &std::path::Path, runtime_command: PodRuntimeCommand, ) -> Result<(), Box> { - let mut app = App::new(pod_name); + let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let mut app = App::new_with_persistent_input_history(pod_name, &workspace_root); match PodClient::connect(socket_path).await { Ok(client) => {