From 799a8f98d9b8392e7ca6c7696cc390830400b4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Fri, 30 Oct 2026 21:54:38 -0500 Subject: [PATCH 01/11] feat(tui): extract a typed presentation seam with an opt-in Next renderer Add claurst_tui::application, the seam between the interactive loop and the renderers: - SessionScope/SessionGeneration: each query worker tags its events with the generation it was spawned under; clear, rewind (command and in-App) and resume advance the generation, so late events from a replaced conversation no longer mutate the new one. Bridge and relay consumers still receive every event. - App::apply_query_event is the reducer step; the completion bell is returned as a PresentationEffect and performed by the loop. - A bounded delivery queue (1024) between per-turn forwarders and the loop; forwarders wait for space and never drop events. The query loop's own per-turn buffer remains unbounded (query-crate API). - COVEN_CODE_UI=classic|next, read only on the interactive route. Unset or empty selects Classic; unsupported values warn on stderr and in the TUI and start Classic. - An opt-in Next renderer over the TranscriptView projection. Any frame where a surface owns input (permission, overlays, pickers, familiar switcher, slash suggestions) or an error modal shows is drawn by Classic. Next clears Classic mouse hit-test geometry. Classic remains the default and its rendering is unchanged. Headless, stream-json and ACP never read the selector. Refs #408 --- .../crates/cli/src/goal_dispatch_tests.rs | 10 +- src-rust/crates/cli/src/main.rs | 132 +++++++- src-rust/crates/cli/tests/acp_smoke.rs | 9 +- src-rust/crates/cli/tests/engine_contract.rs | 73 +++++ src-rust/crates/tui/src/app.rs | 55 +++- src-rust/crates/tui/src/application/mod.rs | 262 ++++++++++++++++ src-rust/crates/tui/src/application/view.rs | 115 +++++++ src-rust/crates/tui/src/lib.rs | 4 + src-rust/crates/tui/src/next/mod.rs | 182 +++++++++++ .../crates/tui/tests/presentation_parity.rs | 287 ++++++++++++++++++ 10 files changed, 1112 insertions(+), 17 deletions(-) create mode 100644 src-rust/crates/tui/src/application/mod.rs create mode 100644 src-rust/crates/tui/src/application/view.rs create mode 100644 src-rust/crates/tui/src/next/mod.rs create mode 100644 src-rust/crates/tui/tests/presentation_parity.rs diff --git a/src-rust/crates/cli/src/goal_dispatch_tests.rs b/src-rust/crates/cli/src/goal_dispatch_tests.rs index b4edbf5e..063b2244 100644 --- a/src-rust/crates/cli/src/goal_dispatch_tests.rs +++ b/src-rust/crates/cli/src/goal_dispatch_tests.rs @@ -190,7 +190,7 @@ async fn dispatch( messages: MessagesArc, tracker: Arc, ) -> claurst_query::QueryOutcome { - let (events, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + let (events, mut receiver) = tokio::sync::mpsc::channel(1); let drain = tokio::spawn(async move { while receiver.recv().await.is_some() {} }); let model = context .config @@ -210,6 +210,7 @@ async fn dispatch( }, tracker, events, + generation: claurst_tui::application::SessionGeneration::default(), cancel: tokio_util::sync::CancellationToken::new(), } .spawn(messages); @@ -223,7 +224,12 @@ async fn dispatch( panic!("query fixture timed out"); } }; - drain.await.unwrap(); + // A finished turn has already delivered every event: the queue's only + // sender lived in the worker, so the consumer sees it closed promptly. + tokio::time::timeout(std::time::Duration::from_secs(1), drain) + .await + .expect("event queue must close once the turn has finished") + .unwrap(); outcome } diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 33ad3ec8..634222d8 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -2361,13 +2361,32 @@ async fn settle_abandoned_turn( type MessagesArc = Arc>>; +/// Tag one turn's events with its session generation and deliver them to the +/// bounded interactive queue in order. Waits for queue space rather than +/// dropping; ends when the turn's sender is dropped or the loop is gone. +async fn forward_scoped_events( + mut turn_rx: tokio::sync::mpsc::UnboundedReceiver, + events: tokio::sync::mpsc::Sender, + generation: claurst_tui::application::SessionGeneration, +) { + while let Some(event) = turn_rx.recv().await { + let scoped = claurst_tui::application::ScopedQueryEvent { generation, event }; + if events.send(scoped).await.is_err() { + break; + } + } +} + struct InteractiveQuery { client: Arc, tools: Arc>>, context: ToolContext, config: claurst_query::QueryConfig, tracker: Arc, - events: tokio::sync::mpsc::UnboundedSender, + events: tokio::sync::mpsc::Sender, + /// Session generation this turn belongs to; every event it emits is + /// tagged with it so a replaced conversation never receives them. + generation: claurst_tui::application::SessionGeneration, cancel: tokio_util::sync::CancellationToken, } @@ -2383,18 +2402,23 @@ impl InteractiveQuery { )); } let mut msgs = messages.lock().await.clone(); - let outcome = claurst_query::run_query_loop( + // The query loop owns `turn_tx` and drops it when it returns, which + // ends the forwarder; joining both keeps the worker's lifetime + // covering delivery of every event it produced. + let (turn_tx, turn_rx) = tokio::sync::mpsc::unbounded_channel(); + let forward = forward_scoped_events(turn_rx, self.events, self.generation); + let run = claurst_query::run_query_loop( self.client.as_ref(), &mut msgs, self.tools.as_slice(), &self.context, &self.config, self.tracker, - Some(self.events), + Some(turn_tx), self.cancel, None, - ) - .await; + ); + let (outcome, ()) = tokio::join!(run, forward); *messages.lock().await = msgs; outcome }) @@ -2658,10 +2682,28 @@ async fn run_interactive( )) }); + // Renderer selection is interactive-only: report an unsupported value + // before the terminal takes over stderr. + let renderer_selection = claurst_tui::application::select_renderer( + std::env::var(claurst_tui::application::UI_SELECTOR_ENV) + .ok() + .as_deref(), + ); + if let Some(warning) = &renderer_selection.warning { + eprintln!("warning: {warning}"); + } + let renderer = renderer_selection.renderer; + // Set up terminal let mut terminal = setup_terminal()?; let mut app = App::new(live_config.clone(), cost_tracker.clone()); app.config.project_dir = Some(tool_ctx.working_dir.clone()); + // The stderr warning is covered by the alternate screen; repeat it where + // the user can read it. + if let Some(warning) = renderer_selection.warning { + app.notifications + .push(claurst_tui::NotificationKind::Warning, warning, Some(15)); + } refresh_command_discovery(&mut app, &live_config, &tool_ctx.working_dir); if let Some(warning) = resume_warning { app.status_message = Some(warning); @@ -2935,7 +2977,10 @@ async fn run_interactive( // Current cancel token (replaced each turn) let mut cancel: Option = None; - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + let (event_tx, mut event_rx) = mpsc::channel::( + claurst_tui::application::QUERY_EVENT_QUEUE_CAPACITY, + ); + let mut session_scope = claurst_tui::application::SessionScope::default(); // Background bash tasks with notify_on_complete send their terminal-state // info here, where the main loop fans-out to a TUI toast + optional bell. let (bg_completion_tx, mut bg_completion_rx) = @@ -3007,7 +3052,10 @@ async fn run_interactive( } // Draw the UI - terminal.draw(|f| render_app(f, &app))?; + terminal.draw(|f| match renderer { + claurst_tui::application::UiRenderer::Classic => render_app(f, &app), + claurst_tui::application::UiRenderer::Next => claurst_tui::next::render_next(f, &app), + })?; // Poll for crossterm events (keyboard/mouse) with short timeout // unless an auto-submit (queued message) is pending — in which case @@ -3287,6 +3335,7 @@ async fn run_interactive( match cli_result { Some(CommandResult::Exit) => break 'main, Some(CommandResult::ClearConversation) => { + session_scope.advance(); messages.clear(); app.replace_messages(Vec::new()); session.messages.clear(); @@ -3294,6 +3343,7 @@ async fn run_interactive( app.status_message = Some("Conversation cleared.".to_string()); } Some(CommandResult::SetMessages(new_msgs)) => { + session_scope.advance(); let removed = messages.len().saturating_sub(new_msgs.len()); messages = new_msgs.clone(); app.replace_messages(new_msgs); @@ -3327,6 +3377,7 @@ async fn run_interactive( Some("Select what to import from ~/.claude.".to_string()); } Some(CommandResult::ResumeSession(resumed_session)) => { + session_scope.advance(); session = resumed_session; messages = session.messages.clone(); app.replace_messages(messages.clone()); @@ -3949,7 +4000,8 @@ async fn run_interactive( let handle = InteractiveQuery { client: client_clone, tools: tools_arc_clone, context: ctx_clone, - config: qcfg, tracker, events: tx, cancel: ct, + config: qcfg, tracker, events: tx, + generation: session_scope.current(), cancel: ct, }.spawn(msgs_arc_clone); // Store the Arc so we can read messages after task completes @@ -4082,6 +4134,8 @@ async fn run_interactive( } } if !app.is_streaming && app.messages.len() < messages.len() { + // An in-App rewind replaced the visible conversation. + session_scope.advance(); messages = app.messages.clone(); session.messages = messages.clone(); session.updated_at = chrono::Utc::now(); @@ -4166,10 +4220,11 @@ async fn run_interactive( } // Drain query events — also forward relevant ones to the bridge as outbound. - while let Ok(evt) = event_rx.try_recv() { + while let Ok(scoped) = event_rx.try_recv() { + let evt = &scoped.event; // Forward to bridge before consuming (clone only what we need). if let Some(ref runtime) = bridge_runtime { - let outbound: Option = match &evt { + let outbound: Option = match evt { QueryEvent::Stream(claurst_api::AnthropicStreamEvent::ContentBlockDelta { delta: claurst_api::streaming::ContentDelta::TextDelta { text }, index, @@ -4215,7 +4270,7 @@ async fn run_interactive( // Also forward to the BridgeSessionInfo relay channel (best-effort). // This drives the post_bridge_event relay task spawned on Connected. if bridge_session_info.is_some() { - let relay_payload: Option = match &evt { + let relay_payload: Option = match evt { QueryEvent::Stream(claurst_api::AnthropicStreamEvent::ContentBlockDelta { delta: claurst_api::streaming::ContentDelta::TextDelta { text }, .. @@ -4260,7 +4315,10 @@ async fn run_interactive( let _ = relay_ev_tx.try_send(payload); } } - app.handle_query_event(evt); + // Machine consumers above see every event; presentation only + // admits events from the current session generation. + let effects = session_scope.apply(&mut app, scoped); + claurst_tui::application::perform_effects(&effects); } // Drain background-task completion events: surface a toast and @@ -4554,6 +4612,7 @@ async fn run_interactive( config: qcfg, tracker, events: tx, + generation: session_scope.current(), cancel: ct, } .spawn(msgs_arc_clone); @@ -4922,6 +4981,7 @@ async fn run_interactive( config: qcfg, tracker, events: tx, + generation: session_scope.current(), cancel: ct, } .spawn(msgs_arc_clone); @@ -5123,6 +5183,7 @@ async fn run_interactive( config: qcfg, tracker, events: tx, + generation: session_scope.current(), cancel: ct, } .spawn(msgs_arc_clone); @@ -5925,6 +5986,53 @@ fn remote_connection_notification(session_url: &str) -> String { mod tests { use super::*; + #[tokio::test] + async fn scoped_forwarder_delivers_every_event_in_order_under_backpressure() { + let (turn_tx, turn_rx) = tokio::sync::mpsc::unbounded_channel(); + let (events, mut receiver) = tokio::sync::mpsc::channel(1); + let mut scope = claurst_tui::application::SessionScope::default(); + let generation = scope.advance(); + for n in 0..200 { + turn_tx + .send(claurst_query::QueryEvent::Status(format!("event {n}"))) + .unwrap(); + } + drop(turn_tx); + let forward = tokio::spawn(forward_scoped_events(turn_rx, events, generation)); + let mut received = Vec::new(); + while let Some(scoped) = receiver.recv().await { + assert_eq!(scoped.generation, generation); + match scoped.event { + claurst_query::QueryEvent::Status(text) => received.push(text), + other => panic!("unexpected event {other:?}"), + } + // Slow consumer: the queue stays full while the forwarder waits. + tokio::task::yield_now().await; + } + forward.await.unwrap(); + let expected: Vec = (0..200).map(|n| format!("event {n}")).collect(); + assert_eq!(received, expected); + } + + #[tokio::test] + async fn scoped_forwarder_stops_when_the_interactive_loop_is_gone() { + let (turn_tx, turn_rx) = tokio::sync::mpsc::unbounded_channel(); + let (events, receiver) = tokio::sync::mpsc::channel(1); + drop(receiver); + turn_tx + .send(claurst_query::QueryEvent::Status("orphan".into())) + .unwrap(); + // The sender stays alive: the forwarder must end on the closed queue, + // not wait for the turn to finish. + tokio::time::timeout( + std::time::Duration::from_secs(5), + forward_scoped_events(turn_rx, events, Default::default()), + ) + .await + .expect("forwarder must stop once the interactive queue is closed"); + drop(turn_tx); + } + #[test] fn remote_notification_two_byte_boundary() { let prefix = "a".repeat(59); diff --git a/src-rust/crates/cli/tests/acp_smoke.rs b/src-rust/crates/cli/tests/acp_smoke.rs index 6591e93d..35ca1375 100644 --- a/src-rust/crates/cli/tests/acp_smoke.rs +++ b/src-rust/crates/cli/tests/acp_smoke.rs @@ -45,6 +45,9 @@ fn run_with_input( .env("USERPROFILE", scratch.path()) .env("COVEN_CODE_HOME", scratch.path()) .env("COVEN_HOME", scratch.path().join("coven")) + // ACP never reads the interactive renderer selector; an unsupported + // value must leave every ACP smoke result unchanged. + .env("COVEN_CODE_UI", "not-a-renderer") // File-backed input reaches EOF without blocking the timeout supervisor. .stdin(std::fs::File::open(&stdin_path).expect("open ACP input")) // Drain into owned files so waiting for exit cannot fill an output pipe. @@ -103,7 +106,11 @@ fn find_response(stdout: &str, id: i64) -> serde_json::Value { fn initialize_returns_spec_compliant_response() { let request = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":false},"clientInfo":{"name":"smoke","version":"0.0.0"}}} "#; - let (_status, stdout, _stderr) = run_with_input(request, Duration::from_secs(20), None); + let (_status, stdout, stderr) = run_with_input(request, Duration::from_secs(20), None); + assert!( + !stderr.contains("COVEN_CODE_UI"), + "ACP must not read the renderer selector:\n{stderr}" + ); let resp = find_response(&stdout, 1); assert_eq!(resp["jsonrpc"], "2.0"); let result = &resp["result"]; diff --git a/src-rust/crates/cli/tests/engine_contract.rs b/src-rust/crates/cli/tests/engine_contract.rs index 3b16d2c8..a50be69a 100644 --- a/src-rust/crates/cli/tests/engine_contract.rs +++ b/src-rust/crates/cli/tests/engine_contract.rs @@ -604,6 +604,79 @@ fn contract_stream_json_turn_matches_golden_frames() { assert_golden("stream_json_turn.golden.jsonl", &golden); } +// --------------------------------------------------------------------------- +// Renderer selector: interactive-only (`COVEN_CODE_UI`) +// --------------------------------------------------------------------------- + +#[test] +fn contract_machine_routes_ignore_renderer_selector() { + let baseline_scratch = Scratch::new(); + let baseline_version = run_engine(baseline_scratch.engine().arg("--version"), ""); + let baseline_print = run_engine( + baseline_scratch + .engine() + .args(["--print", "ping", "--model", FAKE_MODEL]), + "", + ); + let baseline_stream = run_engine( + baseline_scratch.engine().args(stream_json_args()).args([ + "--session-id", + CONTRACT_SESSION, + "--model", + FAKE_MODEL, + ]), + &user_frame("ping"), + ); + let normalized = |run: &Run| -> Vec { + run.stdout_frames().iter().map(normalize_frame).collect() + }; + + for value in ["next", "classic", "not-a-renderer"] { + let scratch = Scratch::new(); + let version = run_engine( + scratch + .engine() + .env("COVEN_CODE_UI", value) + .arg("--version"), + "", + ); + assert_eq!(version.code, baseline_version.code, "{version}"); + assert_eq!(version.stdout, baseline_version.stdout, "{version}"); + + let print = run_engine( + scratch + .engine() + .env("COVEN_CODE_UI", value) + .args(["--print", "ping", "--model", FAKE_MODEL]), + "", + ); + assert_eq!(print.code, baseline_print.code, "{print}"); + assert_eq!(print.stdout, baseline_print.stdout, "{print}"); + + let stream = run_engine( + scratch + .engine() + .env("COVEN_CODE_UI", value) + .args(stream_json_args()) + .args(["--session-id", CONTRACT_SESSION, "--model", FAKE_MODEL]), + &user_frame("ping"), + ); + assert_eq!(stream.code, baseline_stream.code, "{stream}"); + assert_eq!( + normalized(&stream), + normalized(&baseline_stream), + "{stream}" + ); + + for run in [&version, &print, &stream] { + assert!( + !run.stderr.contains("COVEN_CODE_UI"), + "machine routes must not read the renderer selector ({value})\n{run}" + ); + } + } +} + #[test] fn contract_stream_json_exits_zero_on_eof_without_a_turn() { let scratch = Scratch::new(); diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 2d2756c4..6d4f74de 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -7314,8 +7314,22 @@ impl App { self.invalidate_transcript(); } - /// Process a query event from the agentic loop. + /// Process a query event from the agentic loop and perform the external + /// effects it requests. pub fn handle_query_event(&mut self, event: QueryEvent) { + let effects = self.apply_query_event(event); + crate::application::perform_effects(&effects); + } + + /// Apply a query event to presentation state. + /// + /// External effects are returned instead of performed so the interactive + /// loop, not the reducer step, owns them. + pub fn apply_query_event( + &mut self, + event: QueryEvent, + ) -> Vec { + let mut effects = Vec::new(); // Auto-dismiss error modal when assistant responds match &event { QueryEvent::Stream(_) | QueryEvent::TurnComplete { .. } => { @@ -7481,7 +7495,7 @@ impl App { ); } if self.bell_on_complete { - ring_terminal_bell(); + effects.push(crate::application::PresentationEffect::RingBell); } } } @@ -7540,6 +7554,7 @@ impl App { // Update token count from tracker. self.token_count = self.cost_tracker.total_tokens() as u32; + effects } // ------------------------------------------------------------------- @@ -7928,6 +7943,42 @@ mod tests { app } + #[test] + fn turn_completion_bell_is_returned_as_an_effect_not_performed() { + use crate::application::PresentationEffect; + let complete = || QueryEvent::TurnComplete { + turn: 1, + stop_reason: "end_turn".to_string(), + usage: None, + }; + + let mut app = make_app(); + app.bell_on_complete = true; + app.completion_toast_min_secs = 0; + app.turn_start = Some(std::time::Instant::now()); + assert_eq!( + app.apply_query_event(complete()), + vec![PresentationEffect::RingBell] + ); + + let mut quiet = make_app(); + quiet.bell_on_complete = false; + quiet.completion_toast_min_secs = 0; + quiet.turn_start = Some(std::time::Instant::now()); + assert!(quiet.apply_query_event(complete()).is_empty()); + + let mut cancelled = make_app(); + cancelled.bell_on_complete = true; + cancelled.completion_toast_min_secs = 0; + assert!(cancelled + .apply_query_event(QueryEvent::TurnComplete { + turn: 1, + stop_reason: "aborted".to_string(), + usage: None, + }) + .is_empty()); + } + fn test_command_catalog() -> Vec { ["account", "clear", "help", "model"] .into_iter() diff --git a/src-rust/crates/tui/src/application/mod.rs b/src-rust/crates/tui/src/application/mod.rs new file mode 100644 index 00000000..78affb4d --- /dev/null +++ b/src-rust/crates/tui/src/application/mod.rs @@ -0,0 +1,262 @@ +//! Typed presentation seam shared by the Classic and Next renderers. +//! +//! The interactive loop in the CLI owns every runtime effect (query workers, +//! permission decisions, persistence). This module carries what crosses from +//! that loop into presentation: runtime observations scoped to the session +//! generation that produced them, the external effects a reducer step asks the +//! loop to perform, and the renderer selection. Renderers only read [`App`] +//! state and the [`view`] projection; neither renderer executes tools, +//! decides permissions or persists anything. +//! +//! [`App`]: crate::App + +pub mod view; + +use claurst_query::QueryEvent; + +/// Environment variable that selects the interactive renderer. +pub const UI_SELECTOR_ENV: &str = "COVEN_CODE_UI"; + +/// Identifies the session scope a runtime observation belongs to. +/// +/// The interactive loop advances the generation whenever the visible +/// conversation is replaced (clear, rewind, resume). Observations produced by +/// a worker spawned under an older generation are discarded by +/// [`SessionScope::admits`] instead of mutating the new conversation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct SessionGeneration(u64); + +/// Tracks the current session generation for the interactive loop. +#[derive(Debug, Default)] +pub struct SessionScope { + current: SessionGeneration, +} + +impl SessionScope { + pub fn current(&self) -> SessionGeneration { + self.current + } + + /// Start a new generation. Observations tagged with any earlier + /// generation are no longer admitted. + pub fn advance(&mut self) -> SessionGeneration { + self.current = SessionGeneration(self.current.0.wrapping_add(1)); + self.current + } + + pub fn admits(&self, generation: SessionGeneration) -> bool { + generation == self.current + } + + /// Apply a scoped observation to presentation state when it belongs to + /// the current generation; stale observations change nothing. + pub fn apply(&self, app: &mut crate::App, scoped: ScopedQueryEvent) -> Vec { + if !self.admits(scoped.generation) { + tracing::debug!("discarding query event from a previous session generation"); + return Vec::new(); + } + app.apply_query_event(scoped.event) + } +} + +/// A runtime observation tagged with the generation of the worker that +/// produced it. +#[derive(Debug)] +pub struct ScopedQueryEvent { + pub generation: SessionGeneration, + pub event: QueryEvent, +} + +/// Capacity of the delivery queue between query workers and the interactive +/// loop. +/// +/// Each worker's forwarder waits for space instead of dropping, so semantic +/// observations (tool results, errors, turn completion, durable messages) +/// are never discarded under pressure. The query loop itself still emits +/// into an unbounded per-turn buffer (`run_query_loop` takes an +/// `UnboundedSender`), so a stalled interactive loop backs up into that +/// buffer, not into this queue; bounding the producer needs a query-crate +/// API change. +pub const QUERY_EVENT_QUEUE_CAPACITY: usize = 1024; + +/// An external effect requested by a presentation reducer step. +/// +/// Reducer steps only change [`App`](crate::App) state; anything that +/// reaches outside the process state is returned here and performed by the +/// interactive loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PresentationEffect { + /// Write the terminal bell for a completed long-running turn. + RingBell, +} + +/// Perform effects returned by a reducer step. +pub fn perform_effects(effects: &[PresentationEffect]) { + for effect in effects { + match effect { + PresentationEffect::RingBell => crate::app::ring_terminal_bell(), + } + } +} + +/// Interactive renderer selected by [`UI_SELECTOR_ENV`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UiRenderer { + /// The existing renderer. Default. + #[default] + Classic, + /// Opt-in preview renderer over the same application state. + Next, +} + +/// Result of resolving the renderer selector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RendererSelection { + pub renderer: UiRenderer, + /// Present when the selector held an unsupported value; the caller + /// reports it on stderr before the terminal is initialized. + pub warning: Option, +} + +/// Resolve the renderer from the raw selector value. +/// +/// Unset or empty selects Classic. Values are matched case-insensitively +/// after trimming. Anything else selects Classic with a warning naming the +/// accepted values. Only the interactive route calls this; headless, +/// stream-json, ACP and named-command routes never read the selector. +pub fn select_renderer(raw: Option<&str>) -> RendererSelection { + let value = raw.map(str::trim).unwrap_or_default(); + let renderer = if value.is_empty() || value.eq_ignore_ascii_case("classic") { + UiRenderer::Classic + } else if value.eq_ignore_ascii_case("next") { + UiRenderer::Next + } else { + return RendererSelection { + renderer: UiRenderer::Classic, + warning: Some(format!( + "{UI_SELECTOR_ENV}={value:?} is not supported (expected \"classic\" or \"next\"); starting Classic" + )), + }; + }; + RendererSelection { + renderer, + warning: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_rejects_observations_from_earlier_generations() { + let mut scope = SessionScope::default(); + let first = scope.current(); + assert!(scope.admits(first)); + let second = scope.advance(); + assert_ne!(first, second); + assert!(!scope.admits(first), "stale generation must be rejected"); + assert!(scope.admits(second)); + scope.advance(); + assert!(!scope.admits(second)); + } + + fn text_delta(text: &str) -> QueryEvent { + QueryEvent::Stream(claurst_api::AnthropicStreamEvent::ContentBlockDelta { + index: 0, + delta: claurst_api::streaming::ContentDelta::TextDelta { + text: text.to_string(), + }, + }) + } + + #[test] + fn stale_worker_output_does_not_reach_a_replaced_conversation() { + let mut app = crate::App::new( + claurst_core::config::Config::default(), + claurst_core::cost::CostTracker::new(), + ); + let mut scope = SessionScope::default(); + let old = scope.current(); + scope.apply( + &mut app, + ScopedQueryEvent { + generation: old, + event: text_delta("before clear "), + }, + ); + assert_eq!(app.streaming_text, "before clear "); + + // `/clear` replaces the conversation and starts a new generation. + app.replace_messages(Vec::new()); + app.streaming_text.clear(); + app.is_streaming = false; + let new = scope.advance(); + + // Output still queued from the old worker arrives late. + let effects = scope.apply( + &mut app, + ScopedQueryEvent { + generation: old, + event: text_delta("late"), + }, + ); + assert!(effects.is_empty()); + assert_eq!( + app.streaming_text, "", + "stale delta leaked into new conversation" + ); + assert!(!app.is_streaming, "stale delta restarted streaming state"); + + scope.apply( + &mut app, + ScopedQueryEvent { + generation: new, + event: text_delta("fresh"), + }, + ); + assert_eq!(app.streaming_text, "fresh"); + } + + #[test] + fn selector_defaults_to_classic_without_warning() { + for raw in [None, Some(""), Some(" ")] { + assert_eq!( + select_renderer(raw), + RendererSelection { + renderer: UiRenderer::Classic, + warning: None + }, + "raw={raw:?}" + ); + } + } + + #[test] + fn selector_accepts_classic_and_next_case_insensitively() { + assert_eq!( + select_renderer(Some("classic")).renderer, + UiRenderer::Classic + ); + assert_eq!( + select_renderer(Some("CLASSIC")).renderer, + UiRenderer::Classic + ); + let next = select_renderer(Some(" Next ")); + assert_eq!(next.renderer, UiRenderer::Next); + assert_eq!(next.warning, None); + } + + #[test] + fn selector_rejects_unknown_values_with_classic_and_warning() { + let selection = select_renderer(Some("fancy")); + assert_eq!(selection.renderer, UiRenderer::Classic); + let warning = selection.warning.expect("unknown value must warn"); + assert!(warning.contains(UI_SELECTOR_ENV), "{warning}"); + assert!(warning.contains("fancy"), "{warning}"); + assert!( + warning.contains("classic") && warning.contains("next"), + "{warning}" + ); + } +} diff --git a/src-rust/crates/tui/src/application/view.rs b/src-rust/crates/tui/src/application/view.rs new file mode 100644 index 00000000..2bc8f997 --- /dev/null +++ b/src-rust/crates/tui/src/application/view.rs @@ -0,0 +1,115 @@ +//! Renderer-independent projection of the presentation state. +//! +//! Both renderers derive what they show from the same [`App`] state. This +//! projection names the semantic content a renderer must be able to show: +//! transcript entries, live output, tool activity, an outstanding permission +//! request and the composer draft. It holds copies for display only; it is +//! never a source of authority for permissions, tools or persistence. + +use crate::app::{App, ToolStatus}; +use claurst_core::types::Role; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryRole { + User, + Assistant, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TranscriptEntry { + pub role: EntryRole, + pub text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolActivityState { + Running, + Done, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolActivity { + pub id: String, + pub name: String, + pub state: ToolActivityState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingPermission { + pub tool_use_id: String, + pub tool_name: String, + pub description: String, + pub input_preview: Option, + /// Option labels in display order, each with its key. + pub options: Vec<(char, String)>, + pub selected_option: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TranscriptView { + pub entries: Vec, + pub live_text: String, + pub is_streaming: bool, + pub tools: Vec, + pub permission: Option, + pub status: Option, + pub draft: String, +} + +impl TranscriptView { + pub fn project(app: &App) -> Self { + let entries = app + .messages + .iter() + .filter_map(|message| { + let text = message.get_all_text(); + if text.trim().is_empty() { + return None; + } + let role = match message.role { + Role::User => EntryRole::User, + Role::Assistant => EntryRole::Assistant, + }; + Some(TranscriptEntry { role, text }) + }) + .collect(); + let tools = app + .tool_use_blocks + .iter() + .map(|block| ToolActivity { + id: block.id.clone(), + name: block.name.clone(), + state: match block.status { + ToolStatus::Running => ToolActivityState::Running, + ToolStatus::Done => ToolActivityState::Done, + ToolStatus::Error => ToolActivityState::Error, + }, + }) + .collect(); + let permission = app + .permission_request + .as_ref() + .map(|request| PendingPermission { + tool_use_id: request.tool_use_id.clone(), + tool_name: request.tool_name.clone(), + description: request.description.clone(), + input_preview: request.input_preview.clone(), + options: request + .options + .iter() + .map(|option| (option.key, option.label.clone())) + .collect(), + selected_option: request.selected_option, + }); + Self { + entries, + live_text: app.streaming_text.clone(), + is_streaming: app.is_streaming, + tools, + permission, + status: app.status_message.clone(), + draft: app.prompt_input.text.clone(), + } + } +} diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index e8ff3dff..b826a66f 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -73,6 +73,8 @@ fn should_push_keyboard_enhancement_for_process() -> bool { pub mod agents_view; /// Application state and main event loop. pub mod app; +/// Typed presentation seam shared by the Classic and Next renderers. +pub mod application; /// Model-initiated question dialog (AskUserQuestion tool). pub mod ask_user_dialog; /// Bridge connection state and status badge. @@ -143,6 +145,8 @@ pub mod message_copy; pub mod messages; /// Model picker overlay (/model command). pub mod model_picker; +/// Opt-in Next renderer (`COVEN_CODE_UI=next`). +pub mod next; /// Notification / banner system. pub mod notifications; /// First-launch onboarding / welcome dialog. diff --git a/src-rust/crates/tui/src/next/mod.rs b/src-rust/crates/tui/src/next/mod.rs new file mode 100644 index 00000000..c1342837 --- /dev/null +++ b/src-rust/crates/tui/src/next/mod.rs @@ -0,0 +1,182 @@ +//! Next renderer (opt-in preview, `COVEN_CODE_UI=next`). +//! +//! Renders the shared [`TranscriptView`] projection: one transcript, one +//! composer and a status row. It reads the same [`App`] state that Classic +//! reads and receives input through the same handlers, so selecting it never +//! changes what the runtime executes, persists or authorizes. +//! +//! Next draws only the plain transcript/composer shell. Until #410/#411 +//! deliver Next surfaces for them, any frame in which a surface owns input +//! (permission request, overlays, pickers, dialogs, the familiar switcher, +//! slash suggestions) or an error modal is showing is drawn entirely by +//! Classic, so no choice that holds input focus is ever invisible or +//! partially drawn. + +use crate::app::App; +use crate::application::view::{EntryRole, ToolActivityState, TranscriptView}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Wrap}; +use ratatui::Frame; + +/// Header label; the canonical mark is SVG-only, so cells use plain text. +pub const PREVIEW_LABEL: &str = "Coven · preview"; + +/// Whether this frame must be drawn by Classic because a surface Next does +/// not render yet holds input focus or demands attention. +/// +/// `any_modal_open` covers every input-owning surface in +/// `App::handle_key_event` except the familiar switcher and effort picker, +/// which are checked here without changing the list Classic's Enter gating +/// relies on. +pub fn delegates_to_classic(app: &App) -> bool { + app.any_modal_open() + || app.familiar_switcher_open + || app.effort_picker.visible + || app.notifications.current_is_error() + || !app.prompt_input.suggestions.is_empty() +} + +pub fn render_next(frame: &mut Frame, app: &App) { + if delegates_to_classic(app) { + crate::render::render_app(frame, app); + return; + } + // Next does not register mouse hit-test geometry yet. Clear what an + // earlier Classic frame recorded so selection, copy and context-menu + // hit-tests never target Classic's stale layout. + app.last_msg_area.set(Rect::default()); + app.last_selectable_area.set(Rect::default()); + app.last_input_area.set(Rect::default()); + app.footer_right_column_area.set(Rect::default()); + app.thinking_row_map.borrow_mut().clear(); + app.message_row_map.borrow_mut().clear(); + app.last_row_text.borrow_mut().clear(); + + let view = TranscriptView::project(app); + let area = frame.area(); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(3), + ]) + .split(area); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + PREVIEW_LABEL, + Style::default().add_modifier(Modifier::BOLD), + ))), + rows[0], + ); + render_transcript(frame, &view, rows[1]); + frame.render_widget(Paragraph::new(status_line(&view)), rows[2]); + render_composer(frame, &view, rows[3]); + + if !app.notifications.is_empty() { + crate::notifications::render_notification_banner(frame, &app.notifications, area); + } +} + +/// One logical transcript row before wrapping. +struct Row { + text: String, + style: Style, +} + +fn transcript_rows(view: &TranscriptView) -> Vec { + let label = |text: &str| Row { + text: text.to_string(), + style: Style::default().add_modifier(Modifier::BOLD), + }; + let plain = |text: &str| Row { + text: text.to_string(), + style: Style::default(), + }; + let mut rows = Vec::new(); + for entry in &view.entries { + rows.push(label(match entry.role { + EntryRole::User => "you", + EntryRole::Assistant => "coven", + })); + rows.extend(entry.text.lines().map(plain)); + rows.push(plain("")); + } + for tool in &view.tools { + let state = match tool.state { + ToolActivityState::Running => "running", + ToolActivityState::Done => "done", + ToolActivityState::Error => "error", + }; + rows.push(plain(&format!("[tool {state}] {}", tool.name))); + } + if !view.live_text.is_empty() { + rows.push(label("coven")); + rows.extend(view.live_text.lines().map(plain)); + } + rows +} + +/// Split `text` into chunks of at most `width` display cells. A character +/// wider than the whole width still gets its own chunk. +fn wrap_cells(text: &str, width: usize) -> Vec { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut used = 0; + for ch in text.chars() { + let cells = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if used + cells > width && !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + used = 0; + } + current.push(ch); + used += cells; + } + chunks.push(current); + chunks +} + +fn render_transcript(frame: &mut Frame, view: &TranscriptView, area: Rect) { + let width = usize::from(area.width.max(1)); + let mut lines: Vec> = transcript_rows(view) + .into_iter() + .flat_map(|row| { + wrap_cells(&row.text, width) + .into_iter() + .map(move |chunk| Line::from(Span::styled(chunk, row.style))) + }) + .collect(); + // Keep the newest content in view: drop the oldest rows that do not fit. + let overflow = lines.len().saturating_sub(usize::from(area.height)); + lines.drain(..overflow); + frame.render_widget(Paragraph::new(lines), area); +} + +fn status_line(view: &TranscriptView) -> Line<'static> { + let activity = if view.permission.is_some() { + "awaiting permission" + } else if view.is_streaming { + "working" + } else { + "ready" + }; + let mut text = activity.to_string(); + if let Some(status) = &view.status { + text.push_str(" · "); + text.push_str(status); + } + Line::from(Span::styled( + text, + Style::default().add_modifier(Modifier::DIM), + )) +} + +fn render_composer(frame: &mut Frame, view: &TranscriptView, area: Rect) { + let mut lines = vec![Line::from("─".repeat(usize::from(area.width)))]; + lines.push(Line::from(format!("> {}", view.draft))); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); +} diff --git a/src-rust/crates/tui/tests/presentation_parity.rs b/src-rust/crates/tui/tests/presentation_parity.rs new file mode 100644 index 00000000..4917dabf --- /dev/null +++ b/src-rust/crates/tui/tests/presentation_parity.rs @@ -0,0 +1,287 @@ +//! Classic/Next parity over the shared presentation seam. +//! +//! One synthetic runtime trace is replayed through the shared reducer into +//! one App per renderer. Both must reach identical semantic state, and both +//! renderers must show the semantic content of that state. + +use claurst_api::streaming::ContentDelta; +use claurst_api::AnthropicStreamEvent; +use claurst_core::config::Config; +use claurst_core::cost::CostTracker; +use claurst_core::types::Message; +use claurst_query::QueryEvent; +use claurst_tui::app::App; +use claurst_tui::application::view::TranscriptView; +use claurst_tui::application::{PresentationEffect, ScopedQueryEvent, SessionScope, UiRenderer}; +use claurst_tui::dialogs::{handle_permission_key, PermissionRequest}; +use claurst_tui::NotificationKind; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::backend::TestBackend; +use ratatui::Terminal; + +fn fixture_app() -> App { + let config = Config { + model: Some("claude-baseline-model".to_string()), + provider: Some("anthropic".to_string()), + ..Config::default() + }; + let mut app = App::new(config, CostTracker::new()); + app.has_credentials = true; + app +} + +fn trace() -> Vec { + let delta = |text: &str| { + QueryEvent::Stream(AnthropicStreamEvent::ContentBlockDelta { + index: 0, + delta: ContentDelta::TextDelta { + text: text.to_string(), + }, + }) + }; + vec![ + delta("Reading the contract "), + QueryEvent::ToolStart { + tool_name: "Read".to_string(), + tool_id: "toolu-parity-1".to_string(), + input_json: r#"{"file_path":"ENGINE-CONTRACT.md"}"#.to_string(), + }, + QueryEvent::ToolEnd { + tool_name: "Read".to_string(), + tool_id: "toolu-parity-1".to_string(), + result: "contract text".to_string(), + is_error: false, + }, + delta("now."), + QueryEvent::Status("Thinking about the reply".to_string()), + ] +} + +/// Replay the trace into a fresh App; returns the App and all effects. +fn replay(events: Vec) -> (App, Vec) { + let mut app = fixture_app(); + app.push_message(Message::user("Summarize the engine contract.".to_string())); + app.turn_start = None; + let scope = SessionScope::default(); + let mut effects = Vec::new(); + for event in events { + effects.extend(scope.apply( + &mut app, + ScopedQueryEvent { + generation: scope.current(), + event, + }, + )); + } + (app, effects) +} + +fn draw(app: &App, renderer: UiRenderer, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("test terminal"); + terminal + .draw(|frame| match renderer { + UiRenderer::Classic => claurst_tui::render::render_app(frame, app), + UiRenderer::Next => claurst_tui::next::render_next(frame, app), + }) + .expect("draw frame"); + let buffer = terminal.backend().buffer(); + let width = usize::from(buffer.area.width); + buffer + .content + .chunks(width) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n") +} + +#[test] +fn replay_through_the_shared_reducer_is_deterministic() { + let (classic, classic_effects) = replay(trace()); + let (next, next_effects) = replay(trace()); + assert_eq!(classic_effects, next_effects); + let projected = TranscriptView::project(&classic); + assert_eq!(projected, TranscriptView::project(&next)); + assert_eq!(projected.live_text, "Reading the contract now."); + assert_eq!(projected.tools.len(), 1); + assert_eq!( + projected.status.as_deref(), + Some("Thinking about the reply") + ); +} + +#[test] +fn both_renderers_show_the_projected_transcript_and_live_output() { + let (app, _) = replay(trace()); + for renderer in [UiRenderer::Classic, UiRenderer::Next] { + let frame = draw(&app, renderer, 120, 40); + assert!( + frame.contains("Summarize the engine contract."), + "{renderer:?} lost the user turn:\n{frame}" + ); + assert!( + frame.contains("Reading the contract now."), + "{renderer:?} lost live output:\n{frame}" + ); + } + let next = draw(&app, UiRenderer::Next, 120, 40); + assert!(next.contains(claurst_tui::next::PREVIEW_LABEL), "{next}"); + assert!(next.contains("[tool done] Read"), "{next}"); +} + +fn bash_request(id: &str) -> PermissionRequest { + PermissionRequest::bash( + id.to_string(), + "Bash".to_string(), + "Runs a shell command in the project".to_string(), + "cargo test --workspace".to_string(), + Some("cargo test".to_string()), + ) +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +/// What the interactive loop would submit or decide after a key sequence. +#[derive(Debug, PartialEq, Eq)] +struct Outgoing { + submitted: String, + permission: Option<(String, usize, bool)>, + view: TranscriptView, +} + +/// Drive one App through typing, a permission decision and a second draft, +/// drawing with `renderer` between every key the way the interactive loop +/// does, and record the operations the loop would send. +fn drive(renderer: UiRenderer) -> Outgoing { + let (mut app, _) = replay(trace()); + for ch in "run the tests".chars() { + draw(&app, renderer, 120, 40); + app.handle_key_event(key(KeyCode::Char(ch))); + } + draw(&app, renderer, 120, 40); + let submitted = app.take_input(); + + app.permission_request = Some(bash_request("toolu-parity-2")); + let mut confirmed = false; + for code in [KeyCode::Down, KeyCode::Down, KeyCode::Up, KeyCode::Enter] { + draw(&app, renderer, 120, 40); + let request = app.permission_request.as_mut().expect("pending request"); + confirmed = handle_permission_key(request, key(code)); + } + let permission = app + .permission_request + .take() + .map(|request| (request.tool_use_id, request.selected_option, confirmed)); + + for ch in "next draft".chars() { + draw(&app, renderer, 120, 40); + app.handle_key_event(key(KeyCode::Char(ch))); + } + draw(&app, renderer, 120, 40); + Outgoing { + submitted, + permission, + view: TranscriptView::project(&app), + } +} + +#[test] +fn renderers_produce_identical_outgoing_operations_for_the_same_input() { + let classic = drive(UiRenderer::Classic); + let next = drive(UiRenderer::Next); + assert_eq!(classic, next); + assert_eq!(classic.submitted, "run the tests"); + assert_eq!( + classic.permission, + Some(("toolu-parity-2".to_string(), 1, true)) + ); + assert_eq!(classic.view.draft, "next draft"); +} + +#[test] +fn next_draws_permission_requests_through_classic() { + let (mut app, _) = replay(trace()); + app.permission_request = Some(bash_request("toolu-parity-3")); + assert!(claurst_tui::next::delegates_to_classic(&app)); + let next = draw(&app, UiRenderer::Next, 120, 40); + assert_eq!(next, draw(&app, UiRenderer::Classic, 120, 40)); + let request = app.permission_request.as_ref().expect("request"); + for option in &request.options { + assert!( + next.contains(&option.label), + "next lacks {:?}:\n{next}", + option.label + ); + } +} + +/// Opens one input-owning surface on an App. +type OpenSurface = fn(&mut App); + +#[test] +fn next_hands_every_input_owning_surface_to_classic() { + let cases: Vec<(&str, OpenSurface)> = vec![ + ("help overlay", |app| app.help_overlay.visible = true), + ("familiar switcher", |app| app.familiar_switcher_open = true), + ("effort picker", |app| app.effort_picker.visible = true), + ("response reader", |app| app.response_reader.visible = true), + ("error modal", |app| { + app.push_notification( + NotificationKind::Error, + "synthetic failure".to_string(), + None, + ) + }), + ]; + for (name, open) in cases { + let (mut app, _) = replay(trace()); + assert!( + !claurst_tui::next::delegates_to_classic(&app), + "{name}: plain shell" + ); + open(&mut app); + assert!(claurst_tui::next::delegates_to_classic(&app), "{name}"); + assert_eq!( + draw(&app, UiRenderer::Next, 120, 40), + draw(&app, UiRenderer::Classic, 120, 40), + "{name}" + ); + } +} + +#[test] +fn next_clears_classic_mouse_geometry() { + let (app, _) = replay(trace()); + draw(&app, UiRenderer::Classic, 120, 40); + assert!( + app.last_input_area.get().area() > 0, + "classic records geometry" + ); + draw(&app, UiRenderer::Next, 120, 40); + assert!(app.message_row_map.borrow().is_empty()); + assert!(app.last_row_text.borrow().is_empty()); + assert_eq!(app.last_input_area.get().area(), 0); + assert_eq!(app.last_selectable_area.get().area(), 0); + assert_eq!(app.last_msg_area.get().area(), 0); +} + +#[test] +fn next_keeps_newest_output_visible_at_small_sizes_without_panicking() { + let mut long = trace(); + long.push(QueryEvent::Stream( + AnthropicStreamEvent::ContentBlockDelta { + index: 0, + delta: ContentDelta::TextDelta { + text: "\n".to_string() + &"wide 漢字 text ".repeat(40) + "\nnewest line", + }, + }, + )); + let (app, _) = replay(long); + for (width, height) in [(40, 12), (80, 24), (1, 4)] { + let frame = draw(&app, UiRenderer::Next, width, height); + if width >= 40 { + assert!(frame.contains("newest line"), "{width}x{height}:\n{frame}"); + } + } +} From 56df19cc16f9a2a1b97000ad472fd76f41c35047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Fri, 30 Oct 2026 22:23:22 -0500 Subject: [PATCH 02/11] feat(tui): add Brand-aligned Next primitives, color modes and presentation settings Refs #409 --- src-rust/crates/cli/src/main.rs | 5 + src-rust/crates/core/src/appearance.rs | 244 ++++++++ src-rust/crates/core/src/keybindings.rs | 156 +++++ src-rust/crates/core/src/lib.rs | 16 + src-rust/crates/tui/src/app.rs | 3 + src-rust/crates/tui/src/next/mod.rs | 277 ++++++--- src-rust/crates/tui/src/next/primitives.rs | 290 ++++++++++ src-rust/crates/tui/src/next/style.rs | 355 ++++++++++++ .../golden/next/gallery_16_80x16.styles.txt | 26 + .../golden/next/gallery_256_80x16.styles.txt | 30 + .../tests/golden/next/gallery_ascii_40x16.txt | 16 + .../tests/golden/next/gallery_ascii_80x16.txt | 16 + .../golden/next/gallery_none_80x16.styles.txt | 22 + .../next/gallery_truecolor_80x16.styles.txt | 30 + .../golden/next/gallery_unicode_40x16.txt | 16 + .../golden/next/gallery_unicode_80x16.txt | 16 + src-rust/crates/tui/tests/next_primitives.rs | 532 ++++++++++++++++++ .../crates/tui/tests/presentation_parity.rs | 2 +- 18 files changed, 1988 insertions(+), 64 deletions(-) create mode 100644 src-rust/crates/core/src/appearance.rs create mode 100644 src-rust/crates/tui/src/next/primitives.rs create mode 100644 src-rust/crates/tui/src/next/style.rs create mode 100644 src-rust/crates/tui/tests/golden/next/gallery_16_80x16.styles.txt create mode 100644 src-rust/crates/tui/tests/golden/next/gallery_256_80x16.styles.txt create mode 100644 src-rust/crates/tui/tests/golden/next/gallery_ascii_40x16.txt create mode 100644 src-rust/crates/tui/tests/golden/next/gallery_ascii_80x16.txt create mode 100644 src-rust/crates/tui/tests/golden/next/gallery_none_80x16.styles.txt create mode 100644 src-rust/crates/tui/tests/golden/next/gallery_truecolor_80x16.styles.txt create mode 100644 src-rust/crates/tui/tests/golden/next/gallery_unicode_40x16.txt create mode 100644 src-rust/crates/tui/tests/golden/next/gallery_unicode_80x16.txt create mode 100644 src-rust/crates/tui/tests/next_primitives.rs diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 634222d8..59e87f57 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -2733,6 +2733,11 @@ async fn run_interactive( app.auto_compact_threshold = compact_threshold_pct.clamp(0.0, 100.0).ceil() as u8; app.completion_toast_enabled = settings.completion_toast_enabled(); app.bell_on_complete = settings.bell_on_complete; + app.next_presentation = claurst_tui::next::NextPresentation::from_settings( + &settings, + &claurst_core::keybindings::UserKeybindings::load(&Settings::config_dir()), + |name| std::env::var(name).ok(), + ); // Register this session in the Coven daemon ledger (best-effort, opt-in). // The id is captured here so that a mid-run /resume (which rebinds session.id) diff --git a/src-rust/crates/core/src/appearance.rs b/src-rust/crates/core/src/appearance.rs new file mode 100644 index 00000000..289057c8 --- /dev/null +++ b/src-rust/crates/core/src/appearance.rs @@ -0,0 +1,244 @@ +//! Presentation-only appearance settings for the Next renderer +//! (`nextAppearance` in settings.json). +//! +//! These values change how the terminal draws, never what the engine may do: +//! no field here affects permissions, providers, identity or stored state. +//! Parsing is lenient so a bad value cannot break settings loading: each +//! field falls back to its default on an invalid value, and both unknown +//! keys and rejected values are written back unchanged on save. + +use serde::de::Deserializer; +use serde::ser::{SerializeMap, Serializer}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +macro_rules! choice { + ($(#[$meta:meta])* $name:ident { $($variant:ident => $text:literal),+ $(,)? }) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] + pub enum $name { + #[default] + $($variant),+ + } + + impl $name { + pub const VALUES: &'static [&'static str] = &[$($text),+]; + + pub fn parse(text: &str) -> Option { + match text { + $($text => Some(Self::$variant),)+ + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $text),+ + } + } + } + }; +} + +choice!( + /// Light/dark scheme; `auto` follows `COLORFGBG`. + ThemeChoice { Auto => "auto", Dark => "dark", Light => "light" } +); +choice!( + /// Color capability override; `auto` detects from the environment. + ColorChoice { Auto => "auto", TrueColor => "truecolor", Ansi256 => "256", Ansi16 => "16", None => "none" } +); +choice!( + /// Glyph set; `auto` uses Unicode only under a UTF-8 locale. + GlyphChoice { Auto => "auto", Unicode => "unicode", Ascii => "ascii" } +); +choice!( + /// Vertical spacing between transcript entries. + Density { Comfortable => "comfortable", Compact => "compact" } +); +choice!( + /// Preferred layout; Work and Inspect fall back to Focus until #410. + LayoutChoice { Focus => "focus", Work => "work", Inspect => "inspect" } +); + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NextAppearanceSettings { + pub theme: ThemeChoice, + pub color: ColorChoice, + pub glyphs: GlyphChoice, + pub density: Density, + pub layout: LayoutChoice, + /// Show the key-hint row. + pub hints: bool, + /// Keys this version does not know, and known keys whose value was + /// rejected; serialized back verbatim. + preserved: BTreeMap, + /// Known keys whose value was rejected, in file order. + rejected: Vec, + /// Whether `hints` was set explicitly (so defaults are not written out). + hints_set: bool, +} + +impl NextAppearanceSettings { + /// Keys whose values were rejected and replaced by defaults. + pub fn rejected_keys(&self) -> &[String] { + &self.rejected + } + + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + +impl<'de> Deserialize<'de> for NextAppearanceSettings { + fn deserialize>(deserializer: D) -> Result { + let value = serde_json::Value::deserialize(deserializer)?; + let mut settings = Self::default(); + let serde_json::Value::Object(map) = value else { + // Not an object: keep nothing typed, but never fail settings load. + settings.rejected.push(String::new()); + return Ok(settings); + }; + for (key, value) in map { + let text = value.as_str(); + let accepted = match key.as_str() { + "theme" => text + .and_then(ThemeChoice::parse) + .map(|v| settings.theme = v), + "colorMode" => text + .and_then(ColorChoice::parse) + .map(|v| settings.color = v), + "glyphs" => text + .and_then(GlyphChoice::parse) + .map(|v| settings.glyphs = v), + "density" => text.and_then(Density::parse).map(|v| settings.density = v), + "layout" => text + .and_then(LayoutChoice::parse) + .map(|v| settings.layout = v), + "hints" => value.as_bool().map(|v| { + settings.hints = v; + settings.hints_set = true; + }), + _ => { + settings.preserved.insert(key, value); + continue; + } + }; + if accepted.is_none() { + settings.rejected.push(key.clone()); + settings.preserved.insert(key, value); + } + } + Ok(settings) + } +} + +impl Serialize for NextAppearanceSettings { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(None)?; + let typed: [(&str, Option<&str>); 5] = [ + ( + "theme", + Some(self.theme) + .filter(|v| *v != ThemeChoice::default()) + .map(ThemeChoice::as_str), + ), + ( + "colorMode", + Some(self.color) + .filter(|v| *v != ColorChoice::default()) + .map(ColorChoice::as_str), + ), + ( + "glyphs", + Some(self.glyphs) + .filter(|v| *v != GlyphChoice::default()) + .map(GlyphChoice::as_str), + ), + ( + "density", + Some(self.density) + .filter(|v| *v != Density::default()) + .map(Density::as_str), + ), + ( + "layout", + Some(self.layout) + .filter(|v| *v != LayoutChoice::default()) + .map(LayoutChoice::as_str), + ), + ]; + for (key, value) in typed { + if let Some(value) = value { + map.serialize_entry(key, value)?; + } + } + if self.hints_set { + map.serialize_entry("hints", &self.hints)?; + } + for (key, value) in &self.preserved { + // A rejected known key keeps the user's original text. + map.serialize_entry(key, value)?; + } + map.end() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_values_round_trip_and_defaults_stay_out_of_the_file() { + let parsed: NextAppearanceSettings = serde_json::from_str( + r#"{"theme":"light","colorMode":"256","glyphs":"ascii","density":"compact","layout":"work","hints":true}"#, + ) + .unwrap(); + assert_eq!(parsed.theme, ThemeChoice::Light); + assert_eq!(parsed.color, ColorChoice::Ansi256); + assert_eq!(parsed.glyphs, GlyphChoice::Ascii); + assert_eq!(parsed.density, Density::Compact); + assert_eq!(parsed.layout, LayoutChoice::Work); + assert!(parsed.hints); + assert!(parsed.rejected_keys().is_empty()); + let again: NextAppearanceSettings = + serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap(); + assert_eq!(again, parsed); + + let empty = NextAppearanceSettings::default(); + assert_eq!(serde_json::to_string(&empty).unwrap(), "{}"); + assert!(empty.is_default()); + } + + #[test] + fn invalid_values_fall_back_and_are_preserved_with_unknown_keys() { + let parsed: NextAppearanceSettings = serde_json::from_str( + r#"{"theme":"neon","hints":"yes","futureKey":{"nested":[1,2]},"glyphs":"ascii"}"#, + ) + .unwrap(); + assert_eq!(parsed.theme, ThemeChoice::Auto); + assert!(!parsed.hints); + assert_eq!(parsed.glyphs, GlyphChoice::Ascii); + let mut rejected = parsed.rejected_keys().to_vec(); + rejected.sort(); + assert_eq!(rejected, ["hints", "theme"]); + let written: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap(); + assert_eq!( + written, + serde_json::json!({ + "glyphs": "ascii", + "theme": "neon", + "hints": "yes", + "futureKey": {"nested": [1, 2]} + }) + ); + } + + #[test] + fn a_non_object_value_never_fails_settings_load() { + let parsed: NextAppearanceSettings = serde_json::from_str(r#""dark""#).unwrap(); + assert_eq!(parsed.theme, ThemeChoice::Auto); + assert_eq!(parsed.rejected_keys().len(), 1); + } +} diff --git a/src-rust/crates/core/src/keybindings.rs b/src-rust/crates/core/src/keybindings.rs index 2821f89a..ccc99f08 100644 --- a/src-rust/crates/core/src/keybindings.rs +++ b/src-rust/crates/core/src/keybindings.rs @@ -591,11 +591,36 @@ impl UserKeybindings { pub struct KeybindingResolver { bindings: Vec, pending_chord: Vec, + /// Number of leading entries in `bindings` that are defaults. + default_count: usize, +} + +/// Actions that must remain reachable from the keyboard in their context. +pub const ESSENTIAL_ACTIONS: &[(&str, KeyContext)] = &[ + ("submit", KeyContext::Chat), + ("interrupt", KeyContext::Global), + ("openCommandPalette", KeyContext::Chat), + ("openHelp", KeyContext::Global), + ("yes", KeyContext::Confirmation), + ("no", KeyContext::Confirmation), +]; + +/// A user binding that changes what an already-bound chord does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindingConflict { + /// Chord as written in keybindings.json. + pub chord: String, + pub context: KeyContext, + /// Action the chord resolved to before this binding. + pub replaced: String, + /// Action it resolves to now; `None` when the user unbound it. + pub by: Option, } impl KeybindingResolver { pub fn new(user: &UserKeybindings) -> Self { let mut bindings = default_bindings(); + let default_count = bindings.len(); // Apply user overrides (user bindings win, last match wins) for user_binding in &user.bindings { @@ -617,7 +642,74 @@ impl KeybindingResolver { Self { bindings, pending_chord: Vec::new(), + default_count, + } + } + + /// Resolve `chord` in `context` against the first `limit` bindings with + /// the same rules as [`Self::process`]: Global applies everywhere, the + /// last exact match wins, and a shorter chord that resolves first + /// shadows longer ones. + fn resolve_in( + &self, + limit: usize, + context: &KeyContext, + chord: &[ParsedKeystroke], + ) -> Option<&ParsedBinding> { + let candidates = &self.bindings[..limit]; + (1..=chord.len()).find_map(|len| { + candidates + .iter() + .rev() + .filter(|b| &b.context == context || b.context == KeyContext::Global) + .find(|b| b.chord.as_slice() == &chord[..len]) + }) + } + + /// User bindings that change the action of a chord that was already bound. + pub fn conflicts(&self, user: &UserKeybindings) -> Vec { + let mut conflicts = Vec::new(); + let mut index = self.default_count; + for user_binding in &user.bindings { + // Mirror `new`: only parseable chords became bindings. + if parse_chord(&user_binding.chord).is_none() { + continue; + } + let binding = &self.bindings[index]; + if let Some(previous) = self + .resolve_in(index, &binding.context, &binding.chord) + .and_then(|b| b.action.as_ref()) + { + if binding.action.as_ref() != Some(previous) { + conflicts.push(BindingConflict { + chord: user_binding.chord.clone(), + context: binding.context.clone(), + replaced: previous.clone(), + by: binding.action.clone(), + }); + } + } + index += 1; } + conflicts + } + + /// Essential actions no chord resolves to in their context. + pub fn unreachable_essential_actions(&self) -> Vec<&'static str> { + ESSENTIAL_ACTIONS + .iter() + .filter(|(action, context)| { + !self.bindings.iter().any(|candidate| { + candidate.action.as_deref() == Some(*action) + && (&candidate.context == context + || candidate.context == KeyContext::Global) + && self + .resolve_in(self.bindings.len(), context, &candidate.chord) + .is_some_and(|resolved| resolved.action.as_deref() == Some(*action)) + }) + }) + .map(|(action, _)| *action) + .collect() } /// Process a keystroke, returns action if binding matches @@ -692,6 +784,70 @@ pub enum KeybindingResult { mod tests { use super::*; + fn user(bindings: &[(&str, Option<&str>, Option<&str>)]) -> UserKeybindings { + UserKeybindings { + schema_version: KEYBINDINGS_SCHEMA_VERSION, + bindings: bindings + .iter() + .map(|(chord, action, context)| UserBinding { + chord: chord.to_string(), + action: action.map(str::to_string), + context: context.map(str::to_string), + }) + .collect(), + } + } + + #[test] + fn defaults_have_no_conflicts_and_keep_essential_actions_reachable() { + let none = UserKeybindings::default(); + let resolver = KeybindingResolver::new(&none); + assert!(resolver.conflicts(&none).is_empty()); + assert!(resolver.unreachable_essential_actions().is_empty()); + } + + #[test] + fn overriding_a_bound_chord_is_reported_as_a_conflict() { + let overrides = user(&[ + ("enter", Some("newline"), Some("Chat")), + ("ctrl+g", Some("openHelp"), None), + ("", Some("submit"), None), + ]); + let resolver = KeybindingResolver::new(&overrides); + assert_eq!( + resolver.conflicts(&overrides), + vec![BindingConflict { + chord: "enter".to_string(), + context: KeyContext::Chat, + replaced: "submit".to_string(), + by: Some("newline".to_string()), + }] + ); + // Enter no longer submits and nothing else does. + assert_eq!(resolver.unreachable_essential_actions(), vec!["submit"]); + } + + #[test] + fn rebinding_an_essential_action_elsewhere_keeps_it_reachable() { + let overrides = user(&[ + ("enter", Some("newline"), Some("Chat")), + ("ctrl+s", Some("submit"), Some("Chat")), + ]); + let resolver = KeybindingResolver::new(&overrides); + assert_eq!(resolver.conflicts(&overrides).len(), 1); + assert!(resolver.unreachable_essential_actions().is_empty()); + } + + #[test] + fn unbinding_and_prefix_shadowing_make_actions_unreachable() { + let unbound = user(&[("alt+h", None, None)]); + let resolver = KeybindingResolver::new(&unbound); + let conflicts = resolver.conflicts(&unbound); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].by, None); + assert_eq!(resolver.unreachable_essential_actions(), vec!["openHelp"]); + } + #[test] fn test_parse_keystroke_simple() { let ks = parse_keystroke("enter").unwrap(); diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index 2afc63a9..e0bb3b4d 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -1304,6 +1304,14 @@ pub mod config { /// Whether to reduce motion in UI. Defaults to false. #[serde(default, rename = "reduceMotion")] pub reduce_motion: bool, + /// Presentation-only appearance for the Next renderer. Never affects + /// permissions, providers, identity or stored state. + #[serde( + default, + rename = "nextAppearance", + skip_serializing_if = "crate::appearance::NextAppearanceSettings::is_default" + )] + pub next_appearance: crate::appearance::NextAppearanceSettings, /// Whether to show terminal progress bars. Defaults to true. #[serde(default = "default_true", rename = "terminalProgressBar")] pub terminal_progress_bar: bool, @@ -2313,6 +2321,13 @@ pub mod config { notifications: over.notifications || base.notifications, show_turn_duration: over.show_turn_duration || base.show_turn_duration, reduce_motion: over.reduce_motion || base.reduce_motion, + // Appearance is one presentation unit: a layer that sets it + // replaces the lower layer's value whole. + next_appearance: if over.next_appearance.is_default() { + base.next_appearance + } else { + over.next_appearance + }, terminal_progress_bar: over.terminal_progress_bar || base.terminal_progress_bar, show_cwd: over.show_cwd || base.show_cwd, show_git_branch: over.show_git_branch || base.show_git_branch, @@ -5642,6 +5657,7 @@ pub use oauth::OAuthTokens; // --------------------------------------------------------------------------- pub mod accounts; pub mod analytics; +pub mod appearance; pub mod bash_classifier; pub mod codex_oauth; pub mod context_collapse; diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 6d4f74de..70ab4989 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -667,6 +667,8 @@ pub struct App { pub agent_mode_changed: bool, pub agent_status: Vec<(String, String)>, pub keybindings: KeybindingResolver, + /// Presentation preferences for the Next renderer; Classic ignores them. + pub next_presentation: crate::next::NextPresentation, // CLI control-key routing and the normal UI handler resolve each event once. resolved_keybinding: Option<(KeyEvent, bool, KeybindingResult)>, @@ -1249,6 +1251,7 @@ impl App { accent_color: ACCENT_BUILD, agent_status: Vec::new(), keybindings: KeybindingResolver::new(&user_keybindings), + next_presentation: crate::next::NextPresentation::default(), resolved_keybinding: None, cursor_pos: 0, auto_scroll: true, diff --git a/src-rust/crates/tui/src/next/mod.rs b/src-rust/crates/tui/src/next/mod.rs index c1342837..b6e406eb 100644 --- a/src-rust/crates/tui/src/next/mod.rs +++ b/src-rust/crates/tui/src/next/mod.rs @@ -12,17 +12,105 @@ //! Classic, so no choice that holds input focus is ever invisible or //! partially drawn. +pub mod primitives; +pub mod style; + use crate::app::App; use crate::application::view::{EntryRole, ToolActivityState, TranscriptView}; +use claurst_core::appearance::Density; +use claurst_core::keybindings::{display_binding_for, KeybindingResolver, UserKeybindings}; +use primitives::Status; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Paragraph, Wrap}; +use ratatui::widgets::Paragraph; use ratatui::Frame; +use style::{Appearance, Role}; -/// Header label; the canonical mark is SVG-only, so cells use plain text. +/// Header text after the identity mark; the canonical mark is SVG-only, so +/// cells use plain text. pub const PREVIEW_LABEL: &str = "Coven · preview"; +/// Content of the optional key-hint row, resolved through the configurable +/// keybinding system. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyHints { + /// `