diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index a96d4638..96356c5a 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -163,13 +163,11 @@ pub fn load(data_dir: &Path) -> Result { .map_err(|e| format!("invalid settings file {}: {e}", path.display())) } -pub fn save(data_dir: &Path, settings: &Settings) -> Result<(), String> { - save_bytes(data_dir, settings).map(|_| ()) -} - -/// `save`, also returning the exact bytes written so a caller can fingerprint -/// what IT wrote rather than re-reading a path something else may have -/// replaced in the meantime. +/// Write settings.json and return the exact bytes written. The one caller +/// is `Core::save_settings`, which fingerprints what it wrote rather than +/// re-reading a path something else may have replaced in the meantime, so a +/// session's own save is never mistaken for external drift. Nothing outside +/// the core writes settings.json. pub(crate) fn save_bytes(data_dir: &Path, settings: &Settings) -> Result, String> { let json = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; let destination = settings_path(data_dir); @@ -340,10 +338,10 @@ mod tests { )); std::fs::create_dir_all(&dir).unwrap(); let first = Settings { model: "one".into(), ..Settings::default() }; - save(&dir, &first).unwrap(); + save_bytes(&dir, &first).unwrap(); let mut second = first.clone(); second.model = "vendor/family/two".into(); - save(&dir, &second).unwrap(); + save_bytes(&dir, &second).unwrap(); assert_eq!(load(&dir).unwrap().model, "vendor/family/two"); let leftovers: Vec<_> = std::fs::read_dir(&dir) .unwrap() diff --git a/crates/core/src/ledger.rs b/crates/core/src/ledger.rs index 7b2d6283..20528819 100644 --- a/crates/core/src/ledger.rs +++ b/crates/core/src/ledger.rs @@ -7,9 +7,11 @@ //! so only the process that runs turns can honestly say *when* a capability //! file changed relative to the session lifecycle. The ledger lives outside //! the project (like `trust.json`), where the confined file tools never -//! write. Each record embeds the sha256 of the previous record line, so -//! tampering through bash is at least detectable - the honest ceiling -//! without an OS sandbox, and the same ceiling trust already lives at. +//! write. Each record embeds the sha256 of the previous record line, so a +//! torn write or an edit that does not recompute the chain is detected. A +//! deliberate rewrite of log and pin together is not: there is no key, and +//! bash can reach the data dir. That is the honest ceiling without an OS +//! sandbox, and the same ceiling trust already lives at. //! //! Rollback is deliberately a file operation, not a product: `openmax //! --ledger` prints history with object paths, and restoring is `cp`. The @@ -146,14 +148,28 @@ pub fn sha256_hex(bytes: &[u8]) -> String { hex(&Sha256::digest(bytes)) } -/// Per-project ledger directory under the harness data dir, keyed by the -/// canonical root so symlinked spellings share one history. -pub fn project_dir(data_dir: &Path, project_root: &Path) -> PathBuf { +/// The per-project key every per-project store under the data dir uses: +/// the canonical root's hash, so symlinked spellings share one history. +pub fn project_key(project_root: &Path) -> String { let canonical = project_root .canonicalize() .unwrap_or_else(|_| project_root.to_path_buf()); - let key = sha256_hex(canonical.to_string_lossy().as_bytes()); - data_dir.join("ledger").join(&key[..16]) + // The path's bytes, not a lossy rendering: two roots that differ only + // in invalid UTF-8 would otherwise share a key, and with it a ledger and + // a prompt history. Valid UTF-8 paths hash to the same key either way. + #[cfg(unix)] + let bytes = { + use std::os::unix::ffi::OsStrExt; + canonical.as_os_str().as_bytes().to_vec() + }; + #[cfg(not(unix))] + let bytes = canonical.to_string_lossy().into_owned().into_bytes(); + sha256_hex(&bytes)[..16].to_string() +} + +/// Per-project ledger directory under the harness data dir. +pub fn project_dir(data_dir: &Path, project_root: &Path) -> PathBuf { + data_dir.join("ledger").join(project_key(project_root)) } fn log_path(dir: &Path) -> PathBuf { @@ -1883,6 +1899,20 @@ pub fn describe(changes: &[Change], project_root: &Path) -> Vec { mod tests { use super::*; + /// The key hashes the path's bytes, not a lossy rendering: two roots that + /// differ only in invalid UTF-8 bytes spell the same lossy string, and a + /// shared key would let one project's ledger and prompt history answer + /// for the other's. + #[cfg(unix)] + #[test] + fn project_key_distinguishes_byte_distinct_roots() { + use std::os::unix::ffi::OsStrExt; + let a = PathBuf::from(std::ffi::OsStr::from_bytes(b"/nonexistent/open-max-\xff")); + let b = PathBuf::from(std::ffi::OsStr::from_bytes(b"/nonexistent/open-max-\xfe")); + assert_eq!(a.to_string_lossy(), b.to_string_lossy(), "the lossy spellings collide"); + assert_ne!(project_key(&a), project_key(&b)); + } + /// Verify the hash chain; returns the number of intact records or the /// index where the chain breaks. A test oracle: production verification /// goes through `read_verified`, which also checks the pin. diff --git a/crates/core/src/recall.rs b/crates/core/src/recall.rs index 22e01853..8d045d73 100644 --- a/crates/core/src/recall.rs +++ b/crates/core/src/recall.rs @@ -1,6 +1,7 @@ //! `openmax --recall ""`: deliberate recall over this project's own //! history - session transcripts, compaction archives, compaction digests, -//! session titles, and memory files - ranked, budgeted, and cited. +//! and memory files - ranked, budgeted, and cited. Session titles ride on +//! every hit but are not searched (see `collect_chunks`). //! //! The archive PR made every prune reversible ("Full dropped messages: //! "); the memory PR made facts durable. Recall is the searchable form diff --git a/crates/core/src/registry.rs b/crates/core/src/registry.rs index ce310890..877e9098 100644 --- a/crates/core/src/registry.rs +++ b/crates/core/src/registry.rs @@ -464,8 +464,9 @@ impl Registry { registry } - /// A registry with built-ins only: used for sessions that predate the - /// extensibility layer, so their behavior never changes retroactively. + /// A registry with built-ins only: the shape a session without a + /// manifest resolves to until its first turn re-freezes from disk, and + /// the baseline tests build on. pub fn builtin_only() -> Self { Self::assemble(Vec::new(), Vec::new()) } diff --git a/crates/core/src/state.rs b/crates/core/src/state.rs index 92339e2c..74ff6682 100644 --- a/crates/core/src/state.rs +++ b/crates/core/src/state.rs @@ -285,20 +285,6 @@ impl Core { Ok(()) } - /// Adopt these exact settings as this process's own after a save that - /// went through `config::save` directly (an existing helper with its own - /// tests). Fingerprints the serialization of what was saved, never a - /// re-read of the path. - pub fn adopt_saved_settings(&self, settings: &Settings) { - if let Ok(json) = serde_json::to_string_pretty(settings) { - *self - .settings_disk_fingerprint - .lock() - .unwrap_or_else(|e| e.into_inner()) = - SettingsFingerprint::Bytes(hash_bytes(json.as_bytes())); - } - } - /// Whether settings.json on disk moved since this process last read or /// wrote it. On drift, records the new content (so each distinct change /// is reported once) and returns whether the new bytes would parse - diff --git a/crates/core/tests/settings_receipt.rs b/crates/core/tests/settings_receipt.rs index 0419c1ef..0947ff88 100644 --- a/crates/core/tests/settings_receipt.rs +++ b/crates/core/tests/settings_receipt.rs @@ -291,27 +291,23 @@ async fn a_missing_settings_file_replaced_by_a_directory_is_drift() { let _ = std::fs::remove_dir_all(&env.dir); } -/// The fingerprint after a harness save is of the bytes the harness WROTE, -/// never a re-read of the path. Modeled deterministically: the external -/// replacement lands BEFORE the harness adopts its own save (the interval- -/// race outcome). A re-read-based adoption would swallow the foreign bytes -/// as this process's own; fingerprinting the intended bytes keeps them -/// visible as drift. +/// A harness save fingerprints the bytes it writes, under its lock, so +/// there is no interval in which a foreign write could be adopted as this +/// process's own. What the test can observe from outside is the consequence: +/// an external replacement landing after the save still reads as drift. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_racing_external_replacement_is_not_adopted_by_save() { +async fn an_external_replacement_after_a_save_still_reads_as_drift() { let env = build_env(vec![completion_with_text("only")]).await; let settings_path = env.data.join("settings.json"); let mut s = env.core.settings.lock().unwrap().clone(); s.model = "scripted-2".into(); - // The harness's write (through the direct helper, as /model does)... - open_max_core::config::save(&env.data, &s).unwrap(); - // ...an external replacement lands in the interval... - let mut external = s.clone(); + // The harness's write, the one path every in-app save takes... + env.core.save_settings(&s).unwrap(); + *env.core.settings.lock().unwrap() = s.clone(); + // ...then an external replacement lands. + let mut external = s; external.model = "external-edit".into(); std::fs::write(&settings_path, serde_json::to_string_pretty(&external).unwrap()).unwrap(); - // ...and only now does the harness adopt what IT saved. - env.core.adopt_saved_settings(&s); - *env.core.settings.lock().unwrap() = s; assert!( env.core.settings_disk_changed().is_some(), "the foreign bytes must read as drift, not be adopted as this process's own" diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 67991aa9..7a2b97b6 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -31,7 +31,7 @@ use crate::ui::tool_card::{self, DiffText}; use crate::ui::transcript::{ wrap_lines, Term, Transcript, }; -use crate::ui::{context, extensions, markdown, model_picker, ready}; +use crate::ui::{context, extensions, markdown, model_picker}; /// Where keyboard focus lives in chat mode. #[derive(Clone, Copy, PartialEq, Eq)] @@ -523,7 +523,7 @@ impl App { files_tx: mpsc::UnboundedSender>, ) -> Self { Self { - composer: Composer::new(&core.data_dir), + composer: Composer::new(&core.data_dir, &project), core, project, session_id: None, @@ -1537,15 +1537,12 @@ impl App { } fn persist_model_selection(&mut self, provider: Option, model: String) { - let current = self.core.settings.lock().unwrap().clone(); - match save_model_selection( - &self.core.data_dir, - ¤t, - provider.clone(), - model.clone(), - ) { - Ok(next) => { - self.core.adopt_saved_settings(&next); + let next = model_selection(&self.core.settings.lock().unwrap(), provider.clone(), model.clone()); + // The core's own save: it fingerprints the exact bytes it writes, + // under its lock, so a TUI-authored save never reads as drift and a + // racing external edit still does. Adopted in memory only on success. + match self.core.save_settings(&next) { + Ok(()) => { *self.core.settings.lock().unwrap() = next; let source = provider .map(|name| format!(" from {name}")) @@ -2121,32 +2118,6 @@ impl App { self.transcript.push(block); self.dirty.mark_chat(); } - "theme" => { - match rest.first().map(|s| s.to_ascii_lowercase()).as_deref() { - Some("light" | "day") => { - theme::apply(theme::ThemeId::Light); - self.note("theme: light"); - } - Some("dark" | "night") => { - theme::apply(theme::ThemeId::Dark); - self.note("theme: dark"); - } - Some("catppuccin" | "mocha" | "cat") => { - theme::apply(theme::ThemeId::Catppuccin); - self.note("theme: catppuccin"); - } - Some("mono" | "bw") => { - theme::set_tokens(theme::Tokens::mono()); - self.note("theme: mono"); - } - _ => self.note("usage: /theme dark|light|mono|catppuccin"), - } - self.transcript.invalidate_styles(); - self.hist_reuse_key = None; - self.tail_width = 0; - self.thinking_source.clear(); - self.dirty = Dirty::all(); - } "model" if raw_rest.is_empty() => { let settings = self.core.settings.lock().unwrap().clone(); self.model_picker = Some(model_picker::ModelPickerState::load( @@ -3352,13 +3323,7 @@ impl App { self.perf_layout_ms = layout_started.elapsed().as_secs_f64() * 1000.0; self.perf_selection_ms = 0.0; if self.composer.is_empty() { - ready::render( - Rect { - width: content_w, - ..area - }, - frame.buffer_mut(), - ); + render_ready(Rect { width: content_w, ..area }, frame.buffer_mut()); } return; } @@ -3705,17 +3670,24 @@ fn command_parts(command: &str) -> (&str, &str) { (head, rest) } -fn save_model_selection( - data_dir: &std::path::Path, - current: &config::Settings, - provider: Option, - model: String, -) -> Result { +/// Restrained orientation for an empty chat session: one dim word. Transient +/// chrome, never transcript content, so it never reaches saved sessions or +/// the model; hidden when the area cannot hold it. +fn render_ready(area: Rect, buf: &mut ratatui::buffer::Buffer) { + if area.width < 5 || area.height == 0 { + return; + } + Paragraph::new(Line::from(Span::styled("READY", Style::default().fg(theme::DIM())))) + .render(Rect { height: 1, ..area }, buf); +} + +/// The settings a model selection produces; pure, so the caller decides how +/// and whether to persist them. +fn model_selection(current: &config::Settings, provider: Option, model: String) -> config::Settings { let mut next = current.clone(); next.provider = provider; next.model = model; - config::save(data_dir, &next)?; - Ok(next) + next } /// Single source of truth for `/help` and onboarding copy. @@ -4360,7 +4332,7 @@ mod tests { conversation_layout, elapsed_label, header_path_line, help_line, home_shortened, kv, compact_count, is_shift_tab, paint_text_selection, parse_change_counts, plural, wide_status_right, - presence_title, rect_contains, save_model_selection, turn_end_rings, + model_selection, presence_title, rect_contains, turn_end_rings, App, Dirty, Focus, Presence, TermEvent, MIN_DRAW_INTERVAL, TICK, WAIT_TICK, }; use std::time::Duration; @@ -4624,18 +4596,13 @@ mod tests { fn model_selection_persists_provider_and_complete_id() { let dir = crate::test_temp_dir("openmax-model-save"); fs::create_dir_all(&dir).unwrap(); - let current = config::Settings::default(); + let (core, _rx) = open_max_core::state::Core::new(dir.clone()).unwrap(); let exact = "openrouter/vendor/family/model".to_string(); - let saved = save_model_selection( - &dir, - ¤t, - Some("openrouter".into()), - exact.clone(), - ) - .unwrap(); - assert_eq!(saved.provider.as_deref(), Some("openrouter")); - assert_eq!(saved.model, exact); - let disk = config::load(&dir).unwrap(); + let next = model_selection(&config::Settings::default(), Some("openrouter".into()), exact.clone()); + assert_eq!(next.provider.as_deref(), Some("openrouter")); + assert_eq!(next.model, exact); + core.save_settings(&next).unwrap(); + let disk = config::load(&core.data_dir).unwrap(); assert_eq!(disk.provider.as_deref(), Some("openrouter")); assert_eq!(disk.model, exact); fs::remove_dir_all(dir).unwrap(); @@ -4650,21 +4617,6 @@ mod tests { assert_eq!(command_parts("model"), ("model", "")); } - #[test] - fn model_selection_failure_leaves_current_settings_unchanged() { - let current = config::Settings::default(); - let missing = crate::test_temp_dir("openmax-model-save-missing").join("nested"); - let result = save_model_selection( - &missing, - ¤t, - Some("other".into()), - "other/model".into(), - ); - assert!(result.is_err()); - assert!(current.provider.is_none()); - assert_eq!(current.model, config::Settings::default().model); - } - #[test] fn approval_card_has_focused_default_and_clipped_detail() { let lines = approval_card_lines( @@ -4877,7 +4829,7 @@ mod tests { let (mut app, dir) = app_fixture(); let on_disk = { let settings = app.core.settings.lock().unwrap().clone(); - config::save(&dir, &settings).unwrap(); + app.core.save_settings(&settings).unwrap(); config::load(&dir).unwrap().approval_mode }; assert_eq!(on_disk, config::ApprovalMode::Ask); @@ -4985,7 +4937,7 @@ mod tests { let (mut app, dir) = app_fixture(); { let settings = app.core.settings.lock().unwrap().clone(); - config::save(&dir, &settings).unwrap(); + app.core.save_settings(&settings).unwrap(); } app.on_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE)) @@ -5015,7 +4967,7 @@ mod tests { let (mut app, dir) = app_fixture(); { let settings = app.core.settings.lock().unwrap().clone(); - config::save(&dir, &settings).unwrap(); + app.core.save_settings(&settings).unwrap(); } app.pending_approval = Some(("id".into(), "bash".into(), "sum".into(), "detail".into(), vec![])); diff --git a/crates/tui/src/completion.rs b/crates/tui/src/completion.rs index 978427ab..e09495e2 100644 --- a/crates/tui/src/completion.rs +++ b/crates/tui/src/completion.rs @@ -102,12 +102,6 @@ pub const COMMANDS: &[CommandSpec] = &[ "endpoint, cache, performance, privacy, and network details", true, ), - command( - "theme", - "dark|light|mono|catppuccin", - "switch appearance", - false, - ), command("quit", "", "exit", true), ]; diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 8404c748..91cc8869 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -130,8 +130,13 @@ pub struct Composer { } impl Composer { - pub fn new(data_dir: &std::path::Path) -> Self { - let history_path = data_dir.join("history.json"); + pub fn new(data_dir: &std::path::Path, project_root: &std::path::Path) -> Self { + // Prompt history is per project, keyed by the canonical root like + // every other per-project store: one project's drafts must not + // surface in another project's composer. + let history_path = data_dir + .join("history") + .join(format!("{}.json", open_max_core::ledger::project_key(project_root))); let history: Vec = std::fs::read_to_string(&history_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) @@ -627,6 +632,9 @@ impl Composer { self.history.drain(..overflow); } if let Ok(json) = serde_json::to_string(&self.history) { + if let Some(dir) = self.history_path.parent() { + let _ = std::fs::create_dir_all(dir); + } let _ = std::fs::write(&self.history_path, json); } } @@ -1132,7 +1140,7 @@ mod tests { #[test] fn large_paste_collapses_to_a_marker_and_reads_back_expanded() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let blob: String = (1..=12).map(|i| format!("line {i}\n")).collect(); let blob = blob.trim_end().to_string(); composer.insert_str("before "); @@ -1155,7 +1163,7 @@ mod tests { /// later path, never a second delivery. #[test] fn ctrl_o_expands_the_marker_under_the_cursor_not_the_first() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let blob: String = (1..=12).map(|i| format!("line {i}\n")).collect(); let blob = blob.trim_end().to_string(); composer.insert_paste(&blob); @@ -1191,7 +1199,7 @@ mod tests { /// marker as literal text. #[test] fn an_embedded_marker_spelling_never_captures_a_later_paste() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let mut first: Vec = (1..=11).map(|i| format!("alpha {i}")).collect(); first.push("[pasted #2: 12 lines]".to_string()); composer.insert_paste(&first.join("\n")); @@ -1215,7 +1223,7 @@ mod tests { /// instead of injecting the paste twice. #[test] fn a_typed_marker_duplicate_stays_literal() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let blob: String = (1..=12).map(|i| format!("line {i}\n")).collect(); let blob = blob.trim_end().to_string(); composer.insert_paste(&blob); @@ -1238,7 +1246,7 @@ mod tests { /// "hello\nquoted world", silently merging the suffix into the quote. #[test] fn append_block_lands_at_the_end_never_mid_line() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("hello world"); composer.row = 0; composer.col = 5; // cursor between "hello" and " world" @@ -1251,7 +1259,7 @@ mod tests { /// would silently blow a collapsed paste back into the draft. #[test] fn append_block_never_expands_a_collapsed_paste() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let blob: String = (1..=12).map(|i| format!("line {i}\n")).collect(); composer.insert_paste(blob.trim_end()); composer.append_block("quoted"); @@ -1264,7 +1272,7 @@ mod tests { #[test] fn small_pastes_splice_literally() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_paste("just\nthree\nlines"); assert_eq!(composer.lines.len(), 3); assert_eq!(composer.text(), "just\nthree\nlines"); @@ -1273,7 +1281,7 @@ mod tests { #[test] fn a_single_line_blob_collapses_on_the_char_bound() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let blob = "x".repeat(2000); composer.insert_paste(&blob); assert_eq!(composer.lines[0], "[pasted #1: 2000 chars]"); @@ -1282,7 +1290,7 @@ mod tests { #[test] fn expand_paste_at_cursor_splices_the_bytes_back_for_editing() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let blob: String = (1..=12) .map(|i| format!("line {i}")) .collect::>() @@ -1308,7 +1316,7 @@ mod tests { #[test] fn an_edited_marker_never_expands() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let blob: String = (1..=12).map(|i| format!("l{i}\n")).collect(); composer.insert_paste(blob.trim_end()); // Damage the marker itself; a char merely appended after the closing @@ -1321,7 +1329,7 @@ mod tests { #[test] fn empty_composer_uses_single_purposeful_placeholder() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let (lines, cursor_x, cursor_y) = composer.render(40, 3); assert_eq!(lines.len(), 1); assert_eq!(plain(&lines[0]), "❯ Describe a task"); @@ -1334,7 +1342,7 @@ mod tests { /// way, so a draft and its echo line up column for column. #[test] fn multiline_composer_indents_continuation_rows_with_blank_cells() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("first\nsecond"); let (lines, cursor_x, cursor_y) = composer.render(40, 3); assert_eq!(plain(&lines[0]), "❯ first"); @@ -1347,7 +1355,7 @@ mod tests { /// and there is text above. Rows under it stay blank. #[test] fn only_a_scrolled_top_row_marks_the_gutter() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); for i in 0..10 { composer.insert_str(&format!("line {i:02}\n")); } @@ -1362,7 +1370,7 @@ mod tests { #[test] fn composer_edits_complete_graphemes() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("e\u{301}👩‍💻"); composer.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); @@ -1381,7 +1389,7 @@ mod tests { /// 100 KB minified blob. #[test] fn a_paste_splices_segments_instead_of_inserting_per_char() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); let blob = "x".repeat(10_000); composer.insert_str(&blob); assert_eq!( @@ -1396,7 +1404,7 @@ mod tests { #[test] fn a_crlf_paste_drops_carriage_returns_and_splits_lines() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("first\r\nsecond\rhalf\r\n"); assert_eq!(composer.text(), "first\nsecondhalf\n"); assert_eq!((composer.row, composer.col), (2, 0)); @@ -1404,7 +1412,7 @@ mod tests { #[test] fn a_mid_line_paste_lands_at_the_cursor() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("head tail"); for _ in 0..4 { composer.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); @@ -1443,7 +1451,7 @@ mod tests { #[test] fn composer_cursor_uses_terminal_cell_width() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("漢e\u{301}👩‍💻"); let (_, cursor_x, cursor_y) = composer.render(40, 3); assert_eq!((cursor_x, cursor_y), (7, 0)); @@ -1454,7 +1462,7 @@ mod tests { /// most of the draft was neither visible nor reachable. #[test] fn a_long_line_wraps_into_rows_instead_of_running_past_the_box() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str(&"word ".repeat(20)); // 100 cells on one line let width = 20; @@ -1479,8 +1487,9 @@ mod tests { #[test] fn moving_the_cursor_does_not_re_wrap_the_draft() { // A unique directory that is never created: the composer only reads - // history.json, so this test is isolated from every other one's history. - let mut composer = Composer::new(&crate::test_temp_dir("openmax-composer-wraps")); + // its history file, so this test is isolated from every other one's. + let dir = crate::test_temp_dir("openmax-composer-wraps"); + let mut composer = Composer::new(&dir, &dir); composer.insert_str(&"word ".repeat(200)); composer.render(40, 6); let base = composer.wraps; @@ -1526,7 +1535,7 @@ mod tests { /// press snaps back so typing never happens off screen. #[test] fn the_wheel_reaches_rows_above_the_window_and_typing_snaps_back() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); for i in 0..20 { composer.insert_str(&format!("line {i:02}\n")); } @@ -1557,7 +1566,7 @@ mod tests { /// row pointed at, not on whatever logical line happens to be last. #[test] fn a_click_lands_the_cursor_on_the_row_and_column_pointed_at() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("hello world foo"); let (width, height) = (12, 6); // Rows are "hello " and "world foo"; the gutter costs two cells. @@ -1575,7 +1584,7 @@ mod tests { #[test] fn a_click_respects_double_width_cells() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("漢字ab"); // Cell 2 is the second half of 漢: the cursor goes before it. composer.click_at(40, 6, GUTTER as u16 + 1, 0); @@ -1588,7 +1597,7 @@ mod tests { /// across wrapped rows and across logical lines. #[test] fn dragging_selects_the_source_text_under_the_pointer() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("hello world foo"); let (width, height) = (12, 6); @@ -1598,7 +1607,7 @@ mod tests { assert_eq!(composer.selected_text().as_deref(), Some("hello world")); // Across logical lines the newline comes with it. - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("alpha\nbeta"); composer.click_at(40, height, GUTTER as u16 + 2, 0); composer.drag_to(40, height, GUTTER as u16 + 2, 1); @@ -1610,7 +1619,7 @@ mod tests { /// one-character case that an inclusive endpoint alone cannot express. #[test] fn double_click_selects_the_word_under_the_pointer() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("see crates/tui/src/app.rs:42 now"); let (width, height) = (60, 6); @@ -1626,20 +1635,20 @@ mod tests { ); // A one-character word is a real selection, not an empty one. - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("let x = 1"); assert_eq!(at(&mut composer, 4).as_deref(), Some("x")); } #[test] fn triple_click_selects_the_logical_line_even_where_it_wraps() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("alpha\nhello world foo bar\nomega"); // Width 12 wraps the middle line across rows; a triple-click on any of // them still means the whole logical line. let (width, height) = (12, 6); for row in [0u16, 1] { - let mut c = Composer::new(&std::env::temp_dir()); + let mut c = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); c.insert_str("hello world foo bar"); c.select_line_at(width, height, GUTTER as u16, row); c.finish_selection(); @@ -1660,7 +1669,7 @@ mod tests { /// carries, which is the one thing this surface promises. #[test] fn a_gesture_on_an_empty_line_clears_the_previous_selection() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("alpha\n\nbeta"); let (width, height) = (40, 6); @@ -1686,7 +1695,7 @@ mod tests { /// clamps the same way. #[test] fn a_composer_gesture_drag_never_shrinks_below_the_word() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("alpha beta gamma"); let (width, height) = (40, 6); let cell = |col: usize| (GUTTER + col) as u16; @@ -1713,7 +1722,7 @@ mod tests { /// The guarantee #132 established for the transcript, now true here too. #[test] fn the_release_cell_is_carried_not_dropped() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("fn main() {}"); composer.click_at(40, 6, GUTTER as u16, 0); composer.drag_to(40, 6, GUTTER as u16 + 11, 0); @@ -1725,7 +1734,7 @@ mod tests { /// never swallow the Ctrl+C that follows it. #[test] fn a_click_without_a_drag_leaves_no_selection() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("hello"); composer.click_at(40, 6, GUTTER as u16 + 2, 0); assert!(composer.is_dragging()); @@ -1737,7 +1746,7 @@ mod tests { #[test] fn only_the_selected_cells_are_highlighted() { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("alpha\nbeta"); composer.click_at(40, 6, GUTTER as u16 + 2, 0); composer.drag_to(40, 6, GUTTER as u16 + 2, 1); @@ -1771,7 +1780,7 @@ mod tests { let ctrl_u = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL); // From the last row: presses walk the draft upward to empty. - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("first\nsecond\nthird"); composer.handle_key(ctrl_u); assert_eq!(composer.text(), "first\nsecond"); @@ -1784,7 +1793,7 @@ mod tests { assert_eq!(composer.text(), ""); // From the top row: presses consume downward, no stuck state. - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str("first\nsecond\nthird"); composer.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); composer.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); @@ -1811,7 +1820,7 @@ mod tests { ("multi-line-100k", "let value = compute(input);\n".repeat(3_600)), ] { let t0 = Instant::now(); - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str(&text); let paste_ms = t0.elapsed().as_secs_f64() * 1e3; std::hint::black_box(composer.text()); @@ -1832,7 +1841,7 @@ mod tests { ("paste-10k", "let value = compute(input, &config).unwrap_or_default();\n".repeat(180)), ("paste-100k", "let value = compute(input, &config).unwrap_or_default();\n".repeat(1800)), ] { - let mut composer = Composer::new(&std::env::temp_dir()); + let mut composer = Composer::new(&std::env::temp_dir(), &std::env::temp_dir()); composer.insert_str(&text); // A frame asks twice: once to size the box, once to fill it. let frame = |c: &mut Composer| { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 28872c60..397dc146 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -50,10 +50,15 @@ options: deliberately deleting one; a missing approved hook fails closed). Tools never fail closed: a deleted approved tool needs nothing forgotten - --run-examples with --check, execute each tool's [example] once. - Unsandboxed: needs a trusted project and a tool file - approved with --approve, and honors permissions and - approval_mode exactly as a session does + --run-examples with --check, execute each tool's [example] once, in + a trusted project, under the same permission rules + and pre_tool_use hooks as a turn. In auto, a valid + tool then runs on the host without content + approval. In ask or readonly, an approved tool runs + on the host under approval_mode, and an unapproved + one is probed in a sandbox (no network, writes + confined to a scratch dir); a passing probe + approves nothing --check validate extension files (tools, skills, templates, hooks, permissions, providers, memory) and exit; nonzero if any is broken. @@ -963,8 +968,6 @@ async fn main() -> std::io::Result<()> { std::process::exit(2); } - theme::init(); - // Fullscreen session on the alternate screen: openmax owns the whole // terminal while it runs, and your shell (prompt, history, scrollback) // reappears untouched on exit. diff --git a/crates/tui/src/theme.rs b/crates/tui/src/theme.rs index 8c21046a..25747184 100644 --- a/crates/tui/src/theme.rs +++ b/crates/tui/src/theme.rs @@ -1,224 +1,58 @@ -//! Theme tokens for the TUI. The default is monochrome; color themes remain -//! explicit runtime choices through `/theme`. `NO_COLOR` locks monochrome. +//! Tokens for the TUI: one monochrome palette, the terminal's own foreground +//! and background with dim gray for chrome. There is nothing to switch and +//! nothing to persist, and `NO_COLOR` is honored by construction because +//! nothing here carries a hue. use ratatui::style::Color; -use std::sync::RwLock; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ColorLevel { - Truecolor, - Ansi256, - Ansi16, - Mono, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ThemeId { - Dark, - Light, - Catppuccin, -} - -#[derive(Clone, Copy, Debug)] -pub struct Tokens { - pub accent: Color, - pub dim: Color, - pub code: Color, - pub ok: Color, - pub err: Color, - pub warn: Color, - pub border: Color, - pub user: Color, - pub select: Color, - pub surface: Color, - pub user_bg: Color, - pub composer_bg: Color, -} - -impl Tokens { - pub fn dark() -> Self { - Self { - accent: Color::Rgb(0x63, 0xe0, 0xbd), - dim: Color::DarkGray, - code: Color::Rgb(0xd7, 0xba, 0x7d), - ok: Color::Green, - err: Color::Red, - warn: Color::Yellow, - border: Color::DarkGray, - user: Color::Rgb(0x63, 0xe0, 0xbd), - select: Color::Rgb(0x3a, 0x5a, 0x52), - surface: Color::Rgb(0x20, 0x25, 0x29), - user_bg: Color::Rgb(0x24, 0x2d, 0x2c), - composer_bg: Color::Rgb(0x1d, 0x22, 0x25), - } - } - - pub fn light() -> Self { - Self { - accent: Color::Rgb(0x0b, 0x7a, 0x62), - dim: Color::Gray, - code: Color::Rgb(0x8a, 0x6d, 0x1a), - ok: Color::Green, - err: Color::Red, - warn: Color::Rgb(0xb5, 0x89, 0x00), - border: Color::Gray, - user: Color::Rgb(0x0b, 0x7a, 0x62), - select: Color::Rgb(0xd0, 0xec, 0xe4), - surface: Color::Rgb(0xee, 0xf0, 0xf2), - user_bg: Color::Rgb(0xe3, 0xf2, 0xee), - composer_bg: Color::Rgb(0xf5, 0xf6, 0xf7), - } - } - - /// Catppuccin Mocha-inspired palette for a softer dark session. - pub fn catppuccin() -> Self { - Self { - accent: Color::Rgb(0x89, 0xb4, 0xfa), // blue - dim: Color::Rgb(0x6c, 0x70, 0x86), // overlay0 - code: Color::Rgb(0xf9, 0xe2, 0xaf), // yellow - ok: Color::Rgb(0xa6, 0xe3, 0xa1), // green - err: Color::Rgb(0xf3, 0x8b, 0xa8), // red - warn: Color::Rgb(0xfa, 0xb3, 0x87), // peach - border: Color::Rgb(0x58, 0x5b, 0x70), // surface2 - user: Color::Rgb(0x89, 0xb4, 0xfa), - select: Color::Rgb(0x45, 0x47, 0x5a), - surface: Color::Rgb(0x1e, 0x1e, 0x2e), - user_bg: Color::Rgb(0x25, 0x27, 0x3a), - composer_bg: Color::Rgb(0x18, 0x18, 0x25), - } - } - - pub fn mono() -> Self { - Self { - accent: Color::White, - dim: Color::DarkGray, - code: Color::White, - ok: Color::White, - err: Color::White, - warn: Color::White, - border: Color::DarkGray, - user: Color::White, - select: Color::DarkGray, - surface: Color::Black, - user_bg: Color::Black, - composer_bg: Color::Black, - } - } -} - -fn store() -> &'static RwLock { - static STORE: RwLock = RwLock::new(Tokens { - accent: Color::White, - dim: Color::DarkGray, - code: Color::White, - ok: Color::White, - err: Color::White, - warn: Color::White, - border: Color::DarkGray, - user: Color::White, - select: Color::DarkGray, - surface: Color::Black, - user_bg: Color::Black, - composer_bg: Color::Black, - }); - &STORE -} - -pub fn set_tokens(t: Tokens) { - if let Ok(mut g) = store().write() { - *g = t; - } -} - -pub fn init() { - set_tokens(Tokens::mono()); -} - -pub fn detect_color_level() -> ColorLevel { - if std::env::var_os("NO_COLOR").is_some() { - return ColorLevel::Mono; - } - let colorterm = std::env::var("COLORTERM").unwrap_or_default(); - if colorterm.eq_ignore_ascii_case("truecolor") || colorterm.eq_ignore_ascii_case("24bit") { - return ColorLevel::Truecolor; - } - let term = std::env::var("TERM").unwrap_or_default(); - if term.contains("256color") || term.contains("truecolor") { - return ColorLevel::Ansi256; - } - if term == "dumb" || term.is_empty() { - return ColorLevel::Ansi16; - } - ColorLevel::Ansi256 -} - -pub fn apply(id: ThemeId) { - if matches!(detect_color_level(), ColorLevel::Mono) { - set_tokens(Tokens::mono()); - return; - } - set_tokens(match id { - ThemeId::Dark => Tokens::dark(), - ThemeId::Light => Tokens::light(), - ThemeId::Catppuccin => Tokens::catppuccin(), - }); -} - -fn t() -> Tokens { - store() - .read() - .map(|g| *g) - .unwrap_or_else(|_| Tokens::mono()) -} // Call-site names match the old consts so a simple rename keeps working. #[allow(non_snake_case)] pub fn ACCENT() -> Color { - t().accent + Color::White } #[allow(non_snake_case)] pub fn DIM() -> Color { - t().dim + Color::DarkGray } #[allow(non_snake_case)] pub fn CODE() -> Color { - t().code + Color::White } #[allow(non_snake_case)] pub fn OK() -> Color { - t().ok + Color::White } #[allow(non_snake_case)] pub fn ERR() -> Color { - t().err + Color::White } #[allow(non_snake_case)] pub fn WARN() -> Color { - t().warn + Color::White } #[allow(non_snake_case)] pub fn BORDER() -> Color { - t().border + Color::DarkGray } #[allow(non_snake_case)] pub fn USER() -> Color { - t().user + Color::White } #[allow(non_snake_case)] pub fn SELECT() -> Color { - t().select + Color::DarkGray } #[allow(non_snake_case)] pub fn SURFACE() -> Color { - t().surface + Color::Black } #[allow(non_snake_case)] pub fn USER_BG() -> Color { - t().user_bg + Color::Black } #[allow(non_snake_case)] pub fn COMPOSER_BG() -> Color { - t().composer_bg + Color::Black } #[cfg(test)] @@ -233,35 +67,27 @@ mod tests { } } + /// The one palette carries no hue, which is what makes `NO_COLOR` a + /// no-op and every terminal background safe. #[test] - fn monochrome_tokens_contain_no_hue() { - let tokens = Tokens::mono(); + fn tokens_contain_no_hue() { let colors = [ - tokens.accent, - tokens.dim, - tokens.code, - tokens.ok, - tokens.err, - tokens.warn, - tokens.border, - tokens.user, - tokens.select, - tokens.surface, - tokens.user_bg, - tokens.composer_bg, + ACCENT(), + DIM(), + CODE(), + OK(), + ERR(), + WARN(), + BORDER(), + USER(), + SELECT(), + SURFACE(), + USER_BG(), + COMPOSER_BG(), ]; - assert!(colors.into_iter().all(is_achromatic)); - assert_eq!(tokens.surface, Color::Black); - assert_eq!(tokens.user_bg, Color::Black); - assert_eq!(tokens.composer_bg, Color::Black); + assert_eq!(SURFACE(), Color::Black); + assert_eq!(USER_BG(), Color::Black); + assert_eq!(COMPOSER_BG(), Color::Black); } - - /// The palettes must actually differ where the UI depends on it. - #[test] - fn palettes_are_distinct() { - assert_ne!(Tokens::dark().accent, Tokens::light().accent); - assert_ne!(Tokens::dark().accent, Tokens::catppuccin().accent); - } - } diff --git a/crates/tui/src/ui/mod.rs b/crates/tui/src/ui/mod.rs index 0508e555..d061b75e 100644 --- a/crates/tui/src/ui/mod.rs +++ b/crates/tui/src/ui/mod.rs @@ -2,7 +2,6 @@ pub mod context; pub mod extensions; pub mod markdown; pub mod model_picker; -pub mod ready; pub mod sessions; pub mod text; pub mod tool_card; diff --git a/crates/tui/src/ui/ready.rs b/crates/tui/src/ui/ready.rs deleted file mode 100644 index f7491065..00000000 --- a/crates/tui/src/ui/ready.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Restrained orientation for an empty chat session. -//! -//! This is transient chrome, not transcript content. It disappears as soon as -//! a turn produces history or a live tail, so it never reaches saved sessions -//! or the model context. - -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Style; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Paragraph, Widget}; - -use crate::theme; - -pub fn render(area: Rect, buf: &mut Buffer) { - let lines = lines(area.width, area.height); - if lines.is_empty() { - return; - } - - let draw_area = Rect { - height: (lines.len() as u16).min(area.height), - ..area - }; - Paragraph::new(lines).render(draw_area, buf); -} - -fn lines(width: u16, height: u16) -> Vec> { - if width < 5 || height == 0 { - return Vec::new(); - } - - vec![Line::from(Span::styled( - "READY", - Style::default().fg(theme::DIM()), - ))] -} - -#[cfg(test)] -mod tests { - use super::*; - - fn plain(lines: &[Line<'static>]) -> Vec { - lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() - }) - .collect() - } - - #[test] - fn ready_state_is_one_quiet_signal_at_every_supported_size() { - assert_eq!(plain(&lines(80, 12)), ["READY"]); - assert_eq!(plain(&lines(20, 2)), ["READY"]); - } - - #[test] - fn tiny_state_never_overflows_its_width_or_height() { - assert!(lines(4, 1).is_empty()); - assert!(lines(80, 0).is_empty()); - } -} diff --git a/crates/tui/src/ui/transcript.rs b/crates/tui/src/ui/transcript.rs index 357b2c79..b4dc79b4 100644 --- a/crates/tui/src/ui/transcript.rs +++ b/crates/tui/src/ui/transcript.rs @@ -426,15 +426,6 @@ impl Transcript { self.offset = lines_from_bottom; } - /// Theme changes affect cached line surfaces even when content and width - /// stay constant. - pub fn invalidate_styles(&mut self) { - for block in &mut self.blocks { - block.invalidate(); - } - self.dirty = true; - } - fn rebuild_index(&mut self) { self.block_starts.clear(); self.total = 0; diff --git a/docs/usage.md b/docs/usage.md index 7c6c126b..a7cc8600 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -84,6 +84,8 @@ before resuming. A session that has never saved messages can still start fresh. | `/new` · `/resume` | Fresh session · pick an earlier one | | `/reload` | Force a re-freeze now (it also happens automatically when extension files change) | | `/tools` · `/skills` · `/context` | Session tools, skills, token budget | +| `/compact` | Compact the context now instead of waiting for the budget to force it | +| `/export [path]` | Write the transcript as markdown (default `openmax-.md` in the project) | | `/