diff --git a/docs/keybindings.md b/docs/keybindings.md index 9105c754..eb588d45 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -75,6 +75,8 @@ These bindings are active when focus is in the chat input field. | `Ctrl+G` | goToLine | Jump to a specific line | | `Ctrl+.` | jumpToNextError | Jump to next error / issue | | `Ctrl+Shift+.` | jumpToPreviousError | Jump to previous error / issue | +| `Alt+I` | toggleInspector | Next renderer only: open or close the inspector | +| `Alt+L` | jumpToLive | Next renderer only: return a scrolled transcript to live output | > `Ctrl+A` previously opened the model picker; it now moves the cursor to the line start (matching Emacs/readline). The model picker is now `Ctrl+Shift+A`. Old `keybindings.json` files are auto-migrated. @@ -92,6 +94,21 @@ These bindings are active when Coven Code is displaying a yes/no confirmation pr --- +### Inspector Context (Next renderer) + +Active while the Next renderer's inspector is open (`COVEN_CODE_UI=next`). It +is layered over the chat context: keys it does not bind keep their chat +meaning, so the draft stays editable and `Enter` still submits. Classic leaves +the `Alt+I` and `Alt+L` chords unbound. + +| Key | Action | Description | +|-----|--------|-------------| +| `Escape` / `Alt+I` | closeInspector | Close the inspector and return to the transcript | +| `Tab` | nextInspectorPage | Next page (Activity, Session, Views) | +| `Shift+Tab` | previousInspectorPage | Previous page | +| `Page Up` | scrollInspectorUp | Scroll the inspector up | +| `Page Down` | scrollInspectorDown | Scroll the inspector down | + ## Keybinding Contexts Coven Code uses a context system so that the same key can have different effects depending on where focus is. A binding in a more specific context takes precedence over a binding in a broader context. @@ -101,6 +118,7 @@ Coven Code uses a context system so that the same key can have different effects | `global` | Always active regardless of focus | | `chat` | Active when the chat input field has focus | | `confirmation` | Active when a permission confirmation dialog is open | +| `inspector` | Active while the Next inspector is open; layered over `chat` | | `modelPicker` | Active inside the model selection overlay | | `commandPalette` | Active inside the slash command palette overlay | | `search` | Active while the inline search bar is open | @@ -303,6 +321,8 @@ Pressing `Escape` while the model is streaming a response interrupts the stream. This is equivalent to pressing `Ctrl+C` during streaming, except that `Ctrl+C` also signals any tool calls in progress to abort (via `AbortController`), while `Escape` only stops the stream and allows running tools to finish. +In the Next renderer (`COVEN_CODE_UI=next`), `Escape` cancels the whole turn the same way `Ctrl+C` does, and the status shows `cancelling` until the runtime has actually stopped it. When the Next inspector is open, the first `Escape` closes the inspector instead of cancelling. + --- ### @file Injection with Typeahead diff --git a/scripts/tui-tests/cases/08_next_shell.sh b/scripts/tui-tests/cases/08_next_shell.sh new file mode 100755 index 00000000..734a3a89 --- /dev/null +++ b/scripts/tui-tests/cases/08_next_shell.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Next shell journey (#410): COVEN_CODE_UI=next from a fresh, unconfigured +# home through draft -> inspector -> paste -> submit -> stream -> scroll -> +# resize -> jump to live -> cancel -> quit, then terminal restoration. +# +# The turn runs through the engine's keyless Claude transport against a fake +# `claude` that answers once and then keeps the turn running, so streaming, +# reading position and cancellation are exercised with no network or model. +# The terminal is resized between steps (120x40, 80x24, 40x12, back to +# 120x40). This proves the shell journey only; it is not evidence for tool, +# approval, task, research or memory behavior. + +register_case tc_next_shell + +_next_shell_fake_claude() { + cat >"$1" <<'FAKE' +#!/bin/sh +# Fake `claude`: one 60-line answer, then a turn that does not finish. +cat >/dev/null & +printf '%s\n' '{"type":"system","subtype":"init","session_id":"fake","model":"fake-model"}' +i=1; text="" +while [ "$i" -le 60 ]; do text="${text}fake line $i\\n"; i=$((i + 1)); done +printf '%s\n' "{\"type\":\"assistant\",\"message\":{\"model\":\"fake-model\",\"content\":[{\"type\":\"text\",\"text\":\"${text}\"}]}}" +sleep 6 +touch "$FAKE_CLAUDE_DONE" +printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"usage":{"input_tokens":3,"output_tokens":1}}' +FAKE + chmod +x "$1" +} + +tc_next_shell() { + describe "Next shell journey (COVEN_CODE_UI=next)" + if ! have_tmux; then _skip "tmux not installed"; return 0; fi + + local scratch; scratch="$(mktemp -d)" + mkdir -p "$scratch/home/.coven-code" "$scratch/bin" "$scratch/acme-next" + printf '%s\n' '{"hasCompletedOnboarding": true}' >"$scratch/home/.coven-code/settings.json" + _next_shell_fake_claude "$scratch/bin/claude" + + local saved_prefix="$TUI_LAUNCH_PREFIX" saved_boot="$TUI_BOOT_STRING" + # Scrub anything that could reach a real provider or engine home. + TUI_LAUNCH_PREFIX="cd $(printf '%q' "$scratch/acme-next") && env" + local var + for var in ANTHROPIC_API_KEY ANTHROPIC_BASE_URL OPENAI_API_KEY COVEN_CODE_PROVIDER \ + COVEN_CODE_API_BASE COVEN_CODE_HOME COVEN_CODE_TEST_HOME COVEN_PARENT COVEN_DAEMON_SOCKET; do + TUI_LAUNCH_PREFIX+=" -u $var" + done + TUI_LAUNCH_PREFIX+=" $(printf '%q ' \ + "HOME=$scratch/home" "COVEN_HOME=$scratch/home/.coven" COVEN_CODE_UI=next \ + "COVEN_CODE_CLAUDE_BIN=$scratch/bin/claude" "FAKE_CLAUDE_DONE=$scratch/done")" + TUI_BOOT_STRING="preview" + + if tui_start; then + local s + s="$(tui_capture)" + assert_contains "$s" "acme-next" "workspace line names the project" + assert_contains "$s" "ready" "fresh home opens idle with no provider action" + assert_absent "$s" "Coven v" "Next draws its own shell, not Classic's welcome" + + # ---- draft and the one inspector ------------------------------------ + _tmux resize-window -t "$TUI_SESSION" -x 80 -y 24 + tui_type "draft one" + if wait_for "draft not sent" 5; then _pass "a typed draft is visibly unsent" + else _fail "a typed draft is visibly unsent" "$(tui_capture)"; fi + tui_keys M-i + if wait_for "Activity" 5; then _pass "Alt+I opens the inspector" + else _fail "Alt+I opens the inspector" "$(tui_capture)"; fi + tui_keys Tab + if wait_for "Credentials" 5; then _pass "Tab moves to the Session page" + else _fail "Tab moves to the Session page" "$(tui_capture)"; fi + tui_keys Escape + if wait_absent "Credentials" 5; then _pass "Escape closes the inspector" + else _fail "Escape closes the inspector" "$(tui_capture)"; fi + assert_contains "$(tui_capture)" "draft one" "the draft survives the inspector" + + # ---- pasted newline and slash text never submit ---------------------- + tui_keys C-u + tui_paste "pasted one"$'\r'"/clear" + if wait_for "/clear" 5; then + tui_settle + s="$(tui_capture)" + assert_contains "$s" "draft not sent" "pasted newline and /clear stay in the draft" + assert_absent "$s" "you" "nothing was submitted to the transcript" + else + _fail "pasted text lands in the composer" "$(tui_capture)" + fi + tui_keys C-u + + # ---- submit, stream, scroll away, resize, back to live --------------- + _tmux resize-window -t "$TUI_SESSION" -x 120 -y 40 + tui_type "stream please"; tui_keys Enter + if wait_for "fake line 60" 15; then _pass "the response streams into the transcript" + else _fail "the response streams into the transcript" "$(tui_capture)"; fi + tui_keys PageUp + if wait_for "scrolled up" 5; then _pass "scrolling away shows the way back to live" + else _fail "scrolling away shows the way back to live" "$(tui_capture)"; fi + local size + for size in "80 24" "40 12"; do + _tmux resize-window -t "$TUI_SESSION" -x "${size% *}" -y "${size#* }" + tui_settle + s="$(tui_capture)" + assert_contains "$s" "scrolled up" "the scrolled view survives a resize to ${size/ /x}" + assert_contains "$s" "fake line" "transcript text is still shown at ${size/ /x}" + assert_contains "$s" "working" "the turn is still running at ${size/ /x}" + done + _tmux resize-window -t "$TUI_SESSION" -x 120 -y 40 + tui_keys M-l + if wait_absent "scrolled up" 5; then _pass "Alt+L jumps back to live output" + else _fail "Alt+L jumps back to live output" "$(tui_capture)"; fi + + # ---- cancel: the runtime stops the turn ------------------------------ + tui_keys Escape + if wait_for "ready" 10; then _pass "Escape cancels the running turn" + else _fail "Escape cancels the running turn" "$(tui_capture)"; fi + local waited=0 + while pgrep -f "$scratch/bin/claude" >/dev/null 2>&1 && [ "$waited" -lt 10 ]; do + sleep 0.5; waited=$((waited + 1)) + done + if pgrep -f "$scratch/bin/claude" >/dev/null 2>&1; then + _fail "the cancelled turn's claude process exits" + else + _pass "the cancelled turn's claude process exits" + fi + # The fake finishes 6s after answering; give it the time it would need. + sleep 7 + if [ -e "$scratch/done" ]; then _fail "the cancelled turn did not run to completion" + else _pass "the cancelled turn did not run to completion"; fi + assert_contains "$(tui_capture)" "Cancelled." "the settled outcome is shown" + + # ---- quit and terminal restoration ----------------------------------- + tui_keys C-c; sleep 0.3; tui_keys C-c + # Type the check only once the shell owns the pane again; keys sent + # while the app is still exiting are read by it and lost. + local waited=0 + while [ "$waited" -lt 50 ] && \ + [ "$(_tmux display-message -p -t "$TUI_SESSION" '#{pane_current_command}')" = "coven-code" ]; do + sleep 0.1; waited=$((waited + 1)) + done + tui_type 'stty -a | grep -q -- "-icanon" && echo "RAW_$((40 + 1))" || echo "COOKED_$((40 + 2))"' + tui_keys Enter + if wait_for "COOKED_42" 10; then _pass "quitting restores cooked terminal mode" + else _fail "quitting restores cooked terminal mode" "$(tui_capture)"; fi + assert_eq "$(_tmux display-message -p -t "$TUI_SESSION" '#{alternate_on}')" "0" \ + "quitting leaves the alternate screen" + assert_eq "$(_tmux display-message -p -t "$TUI_SESSION" '#{cursor_flag}')" "1" \ + "quitting restores the cursor" + assert_eq "$(_tmux display-message -p -t "$TUI_SESSION" '#{mouse_any_flag}')" "0" \ + "quitting turns mouse reporting off" + fi + tui_stop + + TUI_LAUNCH_PREFIX="$saved_prefix" + TUI_BOOT_STRING="$saved_boot" + pkill -f "$scratch/bin/claude" 2>/dev/null || true + rm -rf "$scratch" +} diff --git a/scripts/tui-tests/lib.sh b/scripts/tui-tests/lib.sh index 6a6eb75b..268223f9 100755 --- a/scripts/tui-tests/lib.sh +++ b/scripts/tui-tests/lib.sh @@ -27,6 +27,7 @@ set -uo pipefail : "${TUI_SETTLE:=0.6}" # seconds to let a keypress redraw : "${TUI_LOG_DIR:=}" # if set, failing-case pane captures are written here : "${REQUIRE_TMUX:=0}" # if 1, a missing tmux is a hard error (CI), not a skip +: "${TUI_LAUNCH_PREFIX:=}" # shell text typed before the binary (cd, env); cases set and restore it # --- counters (persist across sourced case files) ----------------------- TESTS_RUN=0 @@ -238,7 +239,7 @@ tui_start() { local args="" if [ "$#" -gt 0 ]; then printf -v args ' %q' "$@"; fi - tui_type "$COVEN_BIN$args"; tui_keys Enter + tui_type "$TUI_LAUNCH_PREFIX$COVEN_BIN$args"; tui_keys Enter if wait_for "$TUI_BOOT_STRING" "$TUI_WAIT_TIMEOUT"; then return 0 diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index e3c4d6c2..1ac63e5c 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -434,6 +434,9 @@ fn abandon_interactive_turn( if let Some(cancel) = cancel { cancel.cancel(); } + // This is the cancellation a key may have requested; it now waits on + // the worker, which the loop settles once the turn has stopped. + app.take_cancellation_request(); } #[tokio::main] @@ -2735,6 +2738,7 @@ async fn run_interactive( app.bell_on_complete = settings.bell_on_complete; let user_keybindings = claurst_core::keybindings::UserKeybindings::load(&Settings::config_dir()); + app.renderer = renderer; app.next_presentation = claurst_tui::next::NextPresentation::from_settings(&settings, &user_keybindings, |name| { std::env::var(name).ok() @@ -3069,6 +3073,7 @@ async fn run_interactive( claurst_tui::application::UiRenderer::Classic => render_app(f, &app), claurst_tui::application::UiRenderer::Next => claurst_tui::next::render_next(f, &app), })?; + app.settle_scroll_anchor(); // Poll for crossterm events (keyboard/mouse) with short timeout // unless an auto-submit (queued message) is pending — in which case @@ -4505,6 +4510,11 @@ async fn run_interactive( } Ok(TuiBridgeEvent::Cancelled) => { abandon_interactive_turn(&mut goal_turn, &mut app, &cancel); + if current_query.is_some() + && app.renderer == claurst_tui::application::UiRenderer::Next + { + app.note_cancellation_sent(); + } app.is_streaming = false; app.status_message = Some("Cancelled by remote control.".to_string()); } @@ -5003,6 +5013,12 @@ async fn run_interactive( } } + // Escape and the interrupt key record a cancel request in the App; + // perform it through the turn's token so the runtime actually stops. + if app.take_cancellation_request() && current_query.is_some() { + abandon_interactive_turn(&mut goal_turn, &mut app, &cancel); + } + // Check if query task is done; sync messages from the task let task_finished = current_query .as_ref() @@ -5225,6 +5241,11 @@ async fn run_interactive( } } + // A cancel is confirmed only once no worker is left running. + if current_query.is_none() { + app.settle_cancellation(); + } + if !app.is_streaming && current_query.is_none() { if let Some(server_name) = app.take_pending_mcp_panel_auth() { let server_config = cmd_ctx diff --git a/src-rust/crates/core/src/keybindings.rs b/src-rust/crates/core/src/keybindings.rs index 88ffe7ba..2013f523 100644 --- a/src-rust/crates/core/src/keybindings.rs +++ b/src-rust/crates/core/src/keybindings.rs @@ -27,6 +27,9 @@ pub enum KeyContext { ModelPicker, Select, Plugin, + /// The Next renderer's contextual inspector. Layered over Chat: keys it + /// does not bind keep their Chat meaning so the composer stays usable. + Inspector, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -295,6 +298,16 @@ pub fn default_bindings() -> Vec { // App shortcuts ("ctrl+shift+a", "openModelPicker", KeyContext::Chat), ("ctrl+k", "openCommandPalette", KeyContext::Chat), + // Next renderer only; Classic treats these chords as unbound. + ("alt+i", "toggleInspector", KeyContext::Chat), + ("alt+l", "jumpToLive", KeyContext::Chat), + // Hints show the newest default, so Escape is listed last. + ("alt+i", "closeInspector", KeyContext::Inspector), + ("escape", "closeInspector", KeyContext::Inspector), + ("tab", "nextInspectorPage", KeyContext::Inspector), + ("shift+tab", "previousInspectorPage", KeyContext::Inspector), + ("pageup", "scrollInspectorUp", KeyContext::Inspector), + ("pagedown", "scrollInspectorDown", KeyContext::Inspector), // ========== CONFIRMATION DIALOGS ========== ("y", "yes", KeyContext::Confirmation), ("enter", "yes", KeyContext::Confirmation), @@ -795,6 +808,32 @@ impl KeybindingResolver { }) } + /// The chord that reaches `action` in `context`, layered over Chat the + /// way the Next inspector resolves keys: the newest binding for the + /// action whose chord is not taken over by a later binding. + pub fn reachable_chord_in(&self, action: &str, context: &KeyContext) -> Option { + let effect = |chord: &[ParsedKeystroke]| { + self.bindings + .iter() + .rev() + .find(|b| &b.context == context && b.chord.as_slice() == chord) + .or_else(|| self.resolve_in(self.bindings.len(), &KeyContext::Chat, chord)) + .and_then(|b| b.action.as_deref()) + }; + self.bindings + .iter() + .rev() + .filter(|b| &b.context == context && b.action.as_deref() == Some(action)) + .find(|b| effect(&b.chord) == Some(action)) + .map(|b| { + b.chord + .iter() + .map(prettify_keystroke) + .collect::>() + .join(" ") + }) + } + /// Essential actions no key reaches in Chat. pub fn unreachable_essential_actions(&self) -> Vec<&'static str> { ESSENTIAL_ACTIONS @@ -809,15 +848,33 @@ impl KeybindingResolver { &mut self, keystroke: ParsedKeystroke, context: &KeyContext, + ) -> KeybindingResult { + self.process_layered(keystroke, std::slice::from_ref(context)) + } + + /// Like [`Self::process`], with `contexts` ordered from most to least + /// specific. An exact match in an earlier context wins over later ones; + /// Global ranks with the last context, and within one rank the last + /// binding wins (user overrides). + pub fn process_layered( + &mut self, + keystroke: ParsedKeystroke, + contexts: &[KeyContext], ) -> KeybindingResult { self.pending_chord.push(keystroke); + let broadest = contexts.len().saturating_sub(1); + let rank = |binding: &ParsedBinding| { + contexts + .iter() + .position(|context| context == &binding.context) + .or((binding.context == KeyContext::Global).then_some(broadest)) + }; - // Find matching bindings in current context + Global - let matches: Vec<&ParsedBinding> = self + let matches: Vec<(usize, &ParsedBinding)> = self .bindings .iter() - .filter(|b| &b.context == context || b.context == KeyContext::Global) .filter(|b| b.chord.starts_with(self.pending_chord.as_slice())) + .filter_map(|b| rank(b).map(|r| (r, b))) .collect(); if matches.is_empty() { @@ -825,15 +882,18 @@ impl KeybindingResolver { return KeybindingResult::NoMatch; } - let exact: Vec<&ParsedBinding> = matches + let exact = matches .iter() - .copied() - .filter(|b| b.chord.len() == self.pending_chord.len()) - .collect(); + .filter(|(_, b)| b.chord.len() == self.pending_chord.len()) + .fold( + None, + |best: Option<(usize, &ParsedBinding)>, &(r, b)| match best { + Some((best_rank, _)) if best_rank < r => best, + _ => Some((r, b)), + }, + ); - if !exact.is_empty() { - // Last match wins (user overrides) - let binding = exact.last().unwrap(); + if let Some((_, binding)) = exact { self.pending_chord.clear(); return match &binding.action { Some(action) => KeybindingResult::Action(action.clone()), @@ -1363,4 +1423,60 @@ mod tests { other => panic!("Expected Action(\"moveRight\"), got {:?}", other), } } + + fn press(resolver: &mut KeybindingResolver, chord: &str, contexts: &[KeyContext]) -> String { + let mut result = KeybindingResult::NoMatch; + for stroke in parse_chord(chord).expect("chord") { + result = resolver.process_layered(stroke, contexts); + } + match result { + KeybindingResult::Action(action) => action, + other => format!("{other:?}"), + } + } + + #[test] + fn layered_contexts_prefer_the_inspector_and_keep_chat_and_global_keys() { + let layered = [KeyContext::Inspector, KeyContext::Chat]; + let mut resolver = KeybindingResolver::new(&UserKeybindings::default()); + assert_eq!(press(&mut resolver, "escape", &layered), "closeInspector"); + assert_eq!(press(&mut resolver, "tab", &layered), "nextInspectorPage"); + // Composer keys the inspector does not bind keep their Chat meaning. + assert_eq!( + press(&mut resolver, "ctrl+k", &layered), + "openCommandPalette" + ); + assert_eq!(press(&mut resolver, "ctrl+a", &layered), "goLineStart"); + assert_eq!(press(&mut resolver, "ctrl+c", &layered), "interrupt"); + // Without the inspector layer the same keys mean their Chat action. + assert_eq!(press(&mut resolver, "tab", &[KeyContext::Chat]), "indent"); + assert_eq!( + press(&mut resolver, "alt+i", &[KeyContext::Chat]), + "toggleInspector" + ); + assert_eq!( + press(&mut resolver, "escape", &[KeyContext::Chat]), + "NoMatch" + ); + + // A user rebinding inside the inspector wins over its default; a + // user Chat binding does not reach into the inspector layer. + let mut resolver = KeybindingResolver::new(&user(&[ + ("ctrl+q", Some("closeInspector"), Some("Inspector")), + ("escape", None, Some("Inspector")), + ("tab", Some("redraw"), Some("Chat")), + ])); + assert_eq!(press(&mut resolver, "ctrl+q", &layered), "closeInspector"); + assert_eq!(press(&mut resolver, "escape", &layered), "Unbound"); + assert_eq!(press(&mut resolver, "tab", &layered), "nextInspectorPage"); + assert_eq!( + resolver.reachable_chord_in("closeInspector", &KeyContext::Inspector), + Some("Ctrl+Q".to_string()) + ); + assert_eq!( + KeybindingResolver::new(&UserKeybindings::default()) + .reachable_chord_in("closeInspector", &KeyContext::Inspector), + Some("Esc".to_string()) + ); + } } diff --git a/src-rust/crates/core/tests/golden/default_keybindings.txt b/src-rust/crates/core/tests/golden/default_keybindings.txt index 400380d8..c6e78959 100644 --- a/src-rust/crates/core/tests/golden/default_keybindings.txt +++ b/src-rust/crates/core/tests/golden/default_keybindings.txt @@ -43,6 +43,14 @@ Chat pageup scrollUp Chat pagedown scrollDown Chat ctrl+shift+a openModelPicker Chat ctrl+k openCommandPalette +Chat alt+i toggleInspector +Chat alt+l jumpToLive +Inspector alt+i closeInspector +Inspector escape closeInspector +Inspector tab nextInspectorPage +Inspector shift+tab previousInspectorPage +Inspector pageup scrollInspectorUp +Inspector pagedown scrollInspectorDown Confirmation y yes Confirmation enter yes Confirmation n no diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 70ab4989..0beff54a 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -669,6 +669,12 @@ pub struct App { pub keybindings: KeybindingResolver, /// Presentation preferences for the Next renderer; Classic ignores them. pub next_presentation: crate::next::NextPresentation, + /// Renderer drawing this session. Next-only keys are unbound in Classic. + pub renderer: crate::application::UiRenderer, + /// Next shell navigation state (inspector, reading anchor). + pub next_shell: crate::next::ShellState, + /// A user request to cancel the running turn, until the runtime settles it. + pub cancellation: crate::application::TurnCancellation, // CLI control-key routing and the normal UI handler resolve each event once. resolved_keybinding: Option<(KeyEvent, bool, KeybindingResult)>, @@ -1252,6 +1258,9 @@ impl App { agent_status: Vec::new(), keybindings: KeybindingResolver::new(&user_keybindings), next_presentation: crate::next::NextPresentation::default(), + renderer: crate::application::UiRenderer::default(), + next_shell: crate::next::ShellState::default(), + cancellation: crate::application::TurnCancellation::default(), resolved_keybinding: None, cursor_pos: 0, auto_scroll: true, @@ -3159,6 +3168,51 @@ impl App { } } + /// Hand a pending cancel request to the interactive loop exactly once. + /// The request then waits on the runtime until [`Self::settle_cancellation`]. + pub fn take_cancellation_request(&mut self) -> bool { + if self.cancellation != crate::application::TurnCancellation::Requested { + return false; + } + self.cancellation = crate::application::TurnCancellation::AwaitingRuntime; + true + } + + /// Record a cancellation the loop performed without a key request + /// (remote control), so it is shown pending until the worker stops. + pub fn note_cancellation_sent(&mut self) { + self.cancellation = crate::application::TurnCancellation::AwaitingRuntime; + } + + /// The loop calls this once no turn worker is running any more. A + /// pending cancellation becomes the confirmed outcome. + pub fn settle_cancellation(&mut self) { + if self.cancellation != crate::application::TurnCancellation::Idle { + self.status_message = Some("Cancelled.".to_string()); + } + self.cancellation = crate::application::TurnCancellation::Idle; + } + + /// Fold the row correction the Next renderer measured while the reader + /// was scrolled away into `scroll_offset`, so later scroll steps move + /// from where the reader actually is. + /// Ask the loop to cancel the running turn through its token. Next only: + /// Classic keeps its existing cancel behavior unchanged. + fn request_turn_cancellation(&mut self) { + if self.renderer == crate::application::UiRenderer::Next { + self.cancellation = crate::application::TurnCancellation::Requested; + } + } + + pub fn settle_scroll_anchor(&mut self) { + let rows = self.next_shell.take_unsettled_rows(); + if !self.auto_scroll { + self.scroll_offset = self + .scroll_offset + .saturating_add_signed(isize::try_from(rows).unwrap_or(0)); + } + } + pub fn invalidate_transcript(&self) { self.transcript_version .set(self.transcript_version.get().wrapping_add(1)); @@ -3857,8 +3911,18 @@ impl App { } let pending = self.keybindings.has_pending_chord(); let context = self.current_key_context(); + // The Next inspector layers its keys over the composer's. + let inspector_layer = self.renderer == crate::application::UiRenderer::Next + && self.next_shell.inspector.is_some() + && context == KeyContext::Chat + && self.prompt_input.suggestions.is_empty(); + let contexts = if inspector_layer { + vec![KeyContext::Inspector, context] + } else { + vec![context] + }; let result = match key_event_to_keystroke(&key) { - Some(stroke) => self.keybindings.process(stroke, &context), + Some(stroke) => self.keybindings.process_layered(stroke, &contexts), None => { self.keybindings.cancel_chord(); KeybindingResult::NoMatch @@ -4935,6 +4999,16 @@ impl App { // ---- Keybinding processor (runs AFTER all dialog checks) ---------- match binding { + // Next-only actions are unbound in Classic; the key falls through + // exactly as it did before they existed. + KeybindingResult::Action(action) + if self.renderer != crate::application::UiRenderer::Next + && crate::next::NEXT_ONLY_ACTIONS.contains(&action.as_str()) => + { + if had_pending_chord { + return false; + } + } KeybindingResult::Action(action) => return self.handle_keybinding_action(&action), KeybindingResult::Pending => return false, KeybindingResult::NoMatch if had_pending_chord => return false, @@ -5094,6 +5168,7 @@ impl App { self.streaming_thinking.clear(); self.tool_use_blocks.clear(); self.status_message = Some("Cancelled.".to_string()); + self.request_turn_cancellation(); self.complete_current_turn_snapshot(true); } @@ -5126,6 +5201,7 @@ impl App { self.streaming_thinking.clear(); self.tool_use_blocks.clear(); self.status_message = Some("Cancelled.".to_string()); + self.request_turn_cancellation(); self.complete_current_turn_snapshot(true); } else { // No text selected and not streaming: handle exit confirmation sequence. @@ -5898,6 +5974,15 @@ impl App { } fn handle_keybinding_action(&mut self, action: &str) -> bool { + if crate::next::NEXT_ONLY_ACTIONS.contains(&action) { + if action == "jumpToLive" { + self.auto_scroll = true; + self.scroll_offset = 0; + self.new_messages_while_scrolled = 0; + } + self.next_shell.apply_action(action); + return false; + } match action { "interrupt" => { if self.is_streaming { @@ -5907,6 +5992,7 @@ impl App { self.streaming_thinking.clear(); self.tool_use_blocks.clear(); self.status_message = Some("Cancelled.".to_string()); + self.request_turn_cancellation(); } else { // Handle exit confirmation: require two exit key presses within 2 seconds. // Always clear the prompt input on Ctrl+C. diff --git a/src-rust/crates/tui/src/application/mod.rs b/src-rust/crates/tui/src/application/mod.rs index 78affb4d..ee927c66 100644 --- a/src-rust/crates/tui/src/application/mod.rs +++ b/src-rust/crates/tui/src/application/mod.rs @@ -99,6 +99,21 @@ pub fn perform_effects(effects: &[PresentationEffect]) { } } +/// Where a user's request to cancel the running turn stands. +/// +/// Presentation records the request; the interactive loop performs it +/// through the turn's cancellation token and settles it once the worker has +/// actually stopped. Until then the request is pending, not done. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TurnCancellation { + #[default] + Idle, + /// Asked for by a key; the loop has not acted yet. + Requested, + /// The loop cancelled the turn's token; the worker has not finished. + AwaitingRuntime, +} + /// Interactive renderer selected by [`UI_SELECTOR_ENV`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum UiRenderer { diff --git a/src-rust/crates/tui/src/application/view.rs b/src-rust/crates/tui/src/application/view.rs index 2bc8f997..56b9b995 100644 --- a/src-rust/crates/tui/src/application/view.rs +++ b/src-rust/crates/tui/src/application/view.rs @@ -7,7 +7,7 @@ //! never a source of authority for permissions, tools or persistence. use crate::app::{App, ToolStatus}; -use claurst_core::types::Role; +use claurst_core::types::{ContentBlock, MessageContent, Role}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum EntryRole { @@ -19,6 +19,9 @@ pub enum EntryRole { pub struct TranscriptEntry { pub role: EntryRole, pub text: String, + /// Text stand-ins for images and documents, since no renderer may + /// depend on a terminal image protocol. + pub attachments: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -55,6 +58,28 @@ pub struct TranscriptView { pub permission: Option, pub status: Option, pub draft: String, + /// Composer byte offset of the cursor in `draft`. + pub draft_cursor: usize, + /// Text stand-ins for attachments waiting to be sent with the draft. + pub draft_attachments: Vec, +} + +/// Text stand-in for one attachment block, or `None` for other blocks. +fn attachment_label(block: &ContentBlock) -> Option { + match block { + ContentBlock::Image { source } => Some(format!( + "[image {}]", + source.media_type.as_deref().unwrap_or("of unknown type") + )), + ContentBlock::Document { source, title, .. } => Some(format!( + "[document {}]", + title + .as_deref() + .or(source.media_type.as_deref()) + .unwrap_or("of unknown type") + )), + _ => None, + } } impl TranscriptView { @@ -64,14 +89,24 @@ impl TranscriptView { .iter() .filter_map(|message| { let text = message.get_all_text(); - if text.trim().is_empty() { + let attachments: Vec = match &message.content { + MessageContent::Blocks(blocks) => { + blocks.iter().filter_map(attachment_label).collect() + } + MessageContent::Text(_) => Vec::new(), + }; + if text.trim().is_empty() && attachments.is_empty() { return None; } let role = match message.role { Role::User => EntryRole::User, Role::Assistant => EntryRole::Assistant, }; - Some(TranscriptEntry { role, text }) + Some(TranscriptEntry { + role, + text, + attachments, + }) }) .collect(); let tools = app @@ -110,6 +145,16 @@ impl TranscriptView { permission, status: app.status_message.clone(), draft: app.prompt_input.text.clone(), + draft_cursor: app.prompt_input.cursor, + draft_attachments: app + .prompt_input + .pending_images + .iter() + .map(|image| match image.dimensions { + Some((width, height)) => format!("[image {} {width}x{height}]", image.label), + None => format!("[image {}]", image.label), + }) + .collect(), } } } diff --git a/src-rust/crates/tui/src/next/layout.rs b/src-rust/crates/tui/src/next/layout.rs new file mode 100644 index 00000000..68168e1c --- /dev/null +++ b/src-rust/crates/tui/src/next/layout.rs @@ -0,0 +1,126 @@ +//! Cell layout for the Next shell. +//! +//! One pure function maps the terminal size and the inspector request to +//! regions, following the thresholds accepted in the TUI v2 design (#421): +//! +//! | Cells | Rule | +//! |---|---| +//! | width < 80 or height < 24 | Focus: the inspector replaces the main pane | +//! | otherwise, width < 120 or height < 32 | Single: the inspector replaces the main pane | +//! | width >= 120 and height >= 32 | Split: transcript, separator and inspector side by side | +//! +//! Rows are given out in priority order so small terminals degrade +//! predictably: composer, status, header, composer rule, main pane, hints. + +use ratatui::layout::Rect; + +/// Narrowest transcript column in Split; the inspector gets the rest. +const SPLIT_TRANSCRIPT_MIN: u16 = 72; +const INSPECTOR_MIN: u16 = 40; +const INSPECTOR_MAX: u16 = 60; +/// Main pane rows kept clear before the optional hint row is shown. +const MAIN_ROWS_BEFORE_HINTS: u16 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SizeClass { + Focus, + Single, + Split, +} + +impl SizeClass { + pub fn of(area: Rect) -> Self { + if area.width < 80 || area.height < 24 { + SizeClass::Focus + } else if area.width < 120 || area.height < 32 { + SizeClass::Single + } else { + SizeClass::Split + } + } +} + +/// Regions of one Next frame. Zero-height regions are not drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShellLayout { + pub size_class: SizeClass, + pub header: Rect, + /// `None` when the inspector replaces the main pane. + pub transcript: Option, + /// Column between transcript and inspector in Split. + pub separator: Option, + pub inspector: Option, + pub status: Rect, + pub hints: Option, + /// Rule above the composer; dropped first on very short terminals. + pub rule: Option, + pub composer: Rect, +} + +/// Lay out a frame. `composer_rows` is the wrapped draft height; it is +/// capped at a third of the screen, but never below one row. +pub fn plan(area: Rect, inspector_open: bool, hint_rows: u16, composer_rows: u16) -> ShellLayout { + let size_class = SizeClass::of(area); + // Each row is taken from what is left, so nothing can underflow. + fn take(remaining: &mut u16, wanted: u16) -> u16 { + let given = wanted.min(*remaining); + *remaining -= given; + given + } + let mut remaining = area.height; + let composer = take( + &mut remaining, + composer_rows.max(1).min((area.height / 3).max(1)), + ); + let status = take(&mut remaining, 1); + // Header and rule each need a main row left over to be worth showing. + let wanted = u16::from(remaining >= 2); + let header = take(&mut remaining, wanted); + let wanted = u16::from(remaining >= 2); + let rule = take(&mut remaining, wanted); + let hints_wanted = if remaining >= hint_rows + MAIN_ROWS_BEFORE_HINTS && area.height >= 12 { + hint_rows + } else { + 0 + }; + let hints = take(&mut remaining, hints_wanted); + let main = remaining; + + let row = |y: u16, height: u16| Rect::new(area.x, y, area.width, height); + let header_rect = row(area.y, header); + let main_rect = row(area.y + header, main); + let status_rect = row(main_rect.y + main, status); + let hints_rect = row(status_rect.y + status, hints); + let rule_rect = row(hints_rect.y + hints, rule); + let composer_rect = row(rule_rect.y + rule, composer); + + let (transcript, separator, inspector) = match (inspector_open, size_class) { + (false, _) => (Some(main_rect), None, None), + (true, SizeClass::Split) => { + let inspector_width = area + .width + .saturating_sub(SPLIT_TRANSCRIPT_MIN + 1) + .clamp(INSPECTOR_MIN, INSPECTOR_MAX); + let transcript_width = area.width - inspector_width - 1; + let at = |x: u16, width: u16| Rect::new(x, main_rect.y, width, main_rect.height); + ( + Some(at(area.x, transcript_width)), + Some(at(area.x + transcript_width, 1)), + Some(at(area.x + transcript_width + 1, inspector_width)), + ) + } + (true, SizeClass::Focus | SizeClass::Single) => (None, None, Some(main_rect)), + }; + + ShellLayout { + size_class, + header: header_rect, + transcript, + separator, + inspector, + status: status_rect, + hints: (hints > 0).then_some(hints_rect), + rule: (rule > 0).then_some(rule_rect), + composer: composer_rect, + } +} diff --git a/src-rust/crates/tui/src/next/mod.rs b/src-rust/crates/tui/src/next/mod.rs index d3ca2372..f6713a8d 100644 --- a/src-rust/crates/tui/src/next/mod.rs +++ b/src-rust/crates/tui/src/next/mod.rs @@ -1,29 +1,34 @@ //! 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. +//! Renders the shared [`TranscriptView`] projection as the T3 shell: a +//! workspace line, the transcript, a status row and a persistent multiline +//! composer, plus one optional inspector laid out by [`layout`]. 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. +//! Until #411 delivers 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. The command palette and `/` suggestions are therefore +//! the existing ones. +pub mod layout; pub mod primitives; pub mod style; use crate::app::App; use crate::application::view::{EntryRole, ToolActivityState, TranscriptView}; +use crate::application::TurnCancellation; use claurst_core::appearance::Density; -use claurst_core::keybindings::{KeybindingResolver, UserKeybindings}; +use claurst_core::keybindings::{KeyContext, KeybindingResolver, UserKeybindings}; use primitives::Status; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::Rect; use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Clear, Paragraph}; use ratatui::Frame; +use std::cell::Cell; use style::{Appearance, Role}; /// Header text after the identity mark; the canonical mark is SVG-only, so @@ -120,75 +125,372 @@ pub fn delegates_to_classic(app: &App) -> bool { || !app.prompt_input.suggestions.is_empty() } +/// Pages of the single Next inspector, in Tab order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InspectorPage { + /// Tool activity and turn state of the current turn. + Activity, + /// Workspace, session, model and credential context. + Session, + /// Destinations of the shared navigation model and their availability. + Views, +} + +impl InspectorPage { + pub const ALL: [InspectorPage; 3] = [ + InspectorPage::Activity, + InspectorPage::Session, + InspectorPage::Views, + ]; + + pub fn title(self) -> &'static str { + match self { + InspectorPage::Activity => "Activity", + InspectorPage::Session => "Session", + InspectorPage::Views => "Views", + } + } + + fn step(self, forward: bool) -> Self { + let count = Self::ALL.len(); + let index = Self::ALL.iter().position(|page| *page == self).unwrap_or(0); + let next = if forward { + (index + 1) % count + } else { + (index + count - 1) % count + }; + Self::ALL[next] + } +} + +/// Keybinding actions only Next handles. Classic leaves their chords +/// unbound, so its behavior does not change. +pub const NEXT_ONLY_ACTIONS: &[&str] = &[ + "toggleInspector", + "closeInspector", + "nextInspectorPage", + "previousInspectorPage", + "scrollInspectorUp", + "scrollInspectorDown", + "jumpToLive", +]; + +const INSPECTOR_SCROLL_STEP: usize = 5; + +/// Transcript rows measured by the last Next frame, used to keep a scrolled +/// reader on the same content while rows arrive, disappear or rewrap. +#[derive(Debug, Clone, Copy, Default)] +struct ReadingAnchor { + width: u16, + total: usize, + /// Row offset from the bottom where the reader actually is, not yet + /// folded into `App::scroll_offset` (may be negative). + unsettled: i64, + /// Rows that arrived since the reader scrolled away. + unseen: usize, +} + +#[derive(Debug, Default)] +pub struct ShellState { + /// The one open inspector page, if any. It owns Escape and Tab; the + /// composer keeps every other key, so the draft is never stranded. + pub inspector: Option, + pub inspector_scroll: usize, + /// Largest useful `inspector_scroll` for the last drawn page. + inspector_scroll_max: Cell, + anchor: Cell, +} + +impl ShellState { + /// Apply one of [`NEXT_ONLY_ACTIONS`]. + pub fn apply_action(&mut self, action: &str) { + match action { + "toggleInspector" => { + self.inspector = match self.inspector { + Some(_) => None, + None => Some(InspectorPage::Activity), + }; + self.inspector_scroll = 0; + } + "closeInspector" => { + self.inspector = None; + self.inspector_scroll = 0; + } + "nextInspectorPage" | "previousInspectorPage" => { + if let Some(page) = self.inspector { + self.inspector = Some(page.step(action == "nextInspectorPage")); + self.inspector_scroll = 0; + } + } + "scrollInspectorUp" => { + self.inspector_scroll = self.inspector_scroll.saturating_sub(INSPECTOR_SCROLL_STEP); + } + "scrollInspectorDown" => { + self.inspector_scroll = self + .inspector_scroll + .saturating_add(INSPECTOR_SCROLL_STEP) + .min(self.inspector_scroll_max.get()); + } + "jumpToLive" => self.anchor.set(ReadingAnchor::default()), + _ => {} + } + } + + /// Signed row correction the loop adds to `App::scroll_offset`. + pub(crate) fn take_unsettled_rows(&self) -> i64 { + let mut anchor = self.anchor.get(); + let rows = std::mem::take(&mut anchor.unsettled); + self.anchor.set(anchor); + rows + } + + /// First visible row of a `total`-row transcript in a `height`-row pane, + /// and how many rows arrived since the reader scrolled away. + /// + /// `scroll_offset` counts rows above the bottom, so rows added or removed + /// below a scrolled reader would move their text. Those changes are + /// added to the offset until the loop settles them. A width change + /// rewraps everything, so the offset is scaled to the new row count; the + /// result is clamped to the scrollable range so later steps take effect + /// immediately. + fn window( + &self, + total: usize, + height: usize, + width: u16, + auto_scroll: bool, + scroll_offset: usize, + ) -> (usize, usize) { + let mut anchor = self.anchor.get(); + let max_scroll = total.saturating_sub(height); + let offset = if auto_scroll { + anchor = ReadingAnchor { + width, + total, + ..ReadingAnchor::default() + }; + 0 + } else { + let as_rows = |n: usize| i64::try_from(n).unwrap_or(i64::MAX); + let current = as_rows(scroll_offset).saturating_add(anchor.unsettled); + let wanted = if anchor.total == 0 { + // Nothing measured yet: take the offset as given. + current + } else if anchor.width == width { + anchor.unseen += total.saturating_sub(anchor.total); + current.saturating_add(as_rows(total) - as_rows(anchor.total)) + } else { + current.saturating_mul(as_rows(total)) / as_rows(anchor.total) + }; + let clamped = wanted.clamp(0, as_rows(max_scroll)); + anchor.unsettled = clamped - as_rows(scroll_offset); + anchor.width = width; + anchor.total = total; + usize::try_from(clamped).unwrap_or(0) + }; + self.anchor.set(anchor); + (max_scroll - offset.min(max_scroll), anchor.unseen) + } +} + 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()); + // Next registers only the transcript as a selection target. Clear the + // per-message maps an earlier Classic frame recorded so right-click and + // expansion hit-tests never target Classic's stale layout. 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 presentation = &app.next_presentation; let appearance = &presentation.appearance; let view = TranscriptView::project(app); let area = frame.area(); - let hint_rows = u16::from(presentation.hints.is_some()); let width = usize::from(area.width.max(1)); - let mut composer: Vec> = primitives::composer(appearance, &view.draft) + + let draft_lines: Vec> = primitives::composer(appearance, &view.draft) .into_iter() .flat_map(|line| wrap_line(line, width)) .collect(); - // The draft grows the composer up to a third of the screen; past that the - // newest lines stay visible, as in the transcript. - let composer_limit = usize::from((area.height / 3).max(1)); - let overflow = composer.len().saturating_sub(composer_limit); - composer.drain(..overflow); - let composer_rows = u16::try_from(composer.len()).unwrap_or(1) + 1; - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - Constraint::Length(hint_rows), - Constraint::Length(composer_rows), - ]) - .split(area); - - frame.render_widget( - Paragraph::new(primitives::header(appearance, "preview")), - rows[0], + let draft_rows = draft_lines.len(); + let mut composer: Vec> = draft_lines; + composer.extend(view.draft_attachments.iter().flat_map(|label| { + wrap_line( + Line::from(Span::styled( + format!(" {label}"), + appearance.style(Role::Evidence), + )), + width, + ) + })); + let hint_rows = u16::from(presentation.hints.is_some()); + let layout = layout::plan( + area, + app.next_shell.inspector.is_some(), + hint_rows, + u16::try_from(composer.len()).unwrap_or(u16::MAX), ); - render_transcript(frame, presentation, &view, rows[1]); + frame.render_widget( - Paragraph::new(status_line(appearance, &view, app.frame_count)), - rows[2], + Paragraph::new(workspace_line(appearance, app, area.width)), + layout.header, ); - if let Some(hints) = &presentation.hints { - frame.render_widget(Paragraph::new(hints_line(appearance, hints)), rows[3]); + match layout.transcript { + Some(transcript) => { + app.last_msg_area.set(transcript); + app.last_selectable_area.set(transcript); + render_transcript(frame, app, &view, transcript); + crate::render::cache_selectable_row_text(frame, app); + crate::render::apply_selection_highlight(frame, app); + } + None => { + app.last_msg_area.set(Rect::default()); + app.last_selectable_area.set(Rect::default()); + app.last_row_text.borrow_mut().clear(); + } + } + if let Some(separator) = layout.separator { + let glyph = primitives::Mark::Divider.glyph(appearance.glyphs); + let column: Vec> = (0..separator.height) + .map(|_| Line::from(Span::styled(glyph, appearance.style(Role::Border)))) + .collect(); + frame.render_widget(Paragraph::new(column), separator); + } + if let (Some(page), Some(inspector)) = (app.next_shell.inspector, layout.inspector) { + render_inspector(frame, app, &view, page, inspector); } frame.render_widget( - Paragraph::new( - std::iter::once(primitives::rule(appearance, rows[4].width)) - .chain(composer) - .collect::>(), - ), - rows[4], + Paragraph::new(status_line(appearance, app, &view)), + layout.status, ); + if let (Some(hints), Some(rect)) = (&presentation.hints, layout.hints) { + frame.render_widget(Paragraph::new(hints_line(appearance, hints)), rect); + } + if let Some(rule) = layout.rule { + frame.render_widget( + Paragraph::new(composer_rule(appearance, &view, rule.width)), + rule, + ); + } + + // When the draft outgrows its rows, keep the newest rows unless that + // would hide the cursor; then scroll just far enough to show it. + let (row, column) = composer_cursor(&view.draft, view.draft_cursor, width); + let visible = usize::from(layout.composer.height); + let mut first = composer.len().saturating_sub(visible); + if row < draft_rows { + first = first.min(row); + if row >= first + visible { + first = row + 1 - visible; + } + } + composer.drain(..first); + frame.render_widget(Paragraph::new(composer), layout.composer); + if row < draft_rows && row >= first { + if let (Ok(y), Ok(x)) = (u16::try_from(row - first), u16::try_from(column)) { + if y < layout.composer.height && x < area.width { + frame.set_cursor_position((layout.composer.x + x, layout.composer.y + y)); + } + } + } if !app.notifications.is_empty() { crate::notifications::render_notification_banner(frame, &app.notifications, area); } } +/// Cells the composer prompt glyph and its following space occupy; the +/// continuation lead is padded to the same width. +const COMPOSER_LEAD: usize = 2; + +/// Wrapped (row, column) of the cursor at byte `cursor` of `draft`, using +/// the same cell-width wrapping as [`wrap_line`]. +fn composer_cursor(draft: &str, cursor: usize, width: usize) -> (usize, usize) { + // Walk one composer line the way `wrap_line` breaks it: a character that + // would cross the edge starts the next row. + let walk = |text: &str| { + let mut row = 0; + let mut used = COMPOSER_LEAD.min(width); + for ch in text.chars() { + let cells = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if used + cells > width && used > 0 { + row += 1; + used = 0; + } + used += cells; + } + (row, used) + }; + let cursor = cursor.min(draft.len()); + let before = draft.get(..cursor).unwrap_or(draft); + let line_start = before.rfind('\n').map_or(0, |index| index + 1); + let earlier_rows: usize = draft[..line_start] + .split_terminator('\n') + .map(|line| walk(line).0 + 1) + .sum(); + let (row, used) = walk(&before[line_start..]); + // A full row keeps the cursor on its last cell rather than on a row + // that belongs to the next line. + (earlier_rows + row, used.min(width.saturating_sub(1))) +} + +fn project_name(app: &App) -> String { + let root = app + .config + .project_dir + .clone() + .or_else(|| std::env::current_dir().ok()); + root.as_deref() + .and_then(std::path::Path::file_name) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "no project".to_string()) +} + +fn model_label(app: &App) -> Option { + if !app.model_name.is_empty() { + return Some(app.model_name.clone()); + } + app.config.model.clone() +} + +/// Header: product mark, then workspace context in priority order. Parts +/// that do not fit are dropped from the end rather than truncated. +fn workspace_line(appearance: &Appearance, app: &App, width: u16) -> Line<'static> { + let separator = format!(" {} ", primitives::Mark::Separator.glyph(appearance.glyphs)); + let mut line = primitives::header(appearance, "preview"); + let mut parts = vec![(project_name(app), Role::Text)]; + parts.push(( + app.session_title + .clone() + .unwrap_or_else(|| "untitled session".to_string()), + Role::TextMuted, + )); + if let Some(model) = model_label(app) { + parts.push((model, Role::TextMuted)); + } + if let Some(familiar) = &app.config.familiar { + // A familiar id is a display name until #412 qualifies identity. + parts.push((format!("{familiar} (unverified)"), Role::Evidence)); + } + for (text, role) in parts { + let cells = + separator.chars().count() + unicode_width::UnicodeWidthStr::width(text.as_str()); + if line.width() + cells > usize::from(width) { + break; + } + line.spans.push(Span::styled( + separator.clone(), + appearance.style(Role::TextSubtle), + )); + line.spans.push(Span::styled(text, appearance.style(role))); + } + line +} + fn transcript_lines(presentation: &NextPresentation, view: &TranscriptView) -> Vec> { let appearance = &presentation.appearance; let text = |content: &str| { @@ -207,17 +509,18 @@ fn transcript_lines(presentation: &NextPresentation, view: &TranscriptView) -> V }, )); lines.extend(entry.text.lines().map(text)); + lines.extend(entry.attachments.iter().map(|label| { + Line::from(Span::styled( + label.clone(), + appearance.style(Role::Evidence), + )) + })); if presentation.density == Density::Comfortable { lines.push(Line::default()); } } for tool in &view.tools { - // Completion is not verification: a finished tool is neutral evidence. - let (status, word) = match tool.state { - ToolActivityState::Running => (Status::Pending, "running"), - ToolActivityState::Done => (Status::Evidence, "done"), - ToolActivityState::Error => (Status::Blocked, "error"), - }; + let (status, word) = tool_status(tool.state); lines.push(primitives::tool_row( appearance, &tool.name, status, word, false, )); @@ -229,6 +532,273 @@ fn transcript_lines(presentation: &NextPresentation, view: &TranscriptView) -> V lines } +/// Completion is not verification: a finished tool is neutral evidence. +fn tool_status(state: ToolActivityState) -> (Status, &'static str) { + match state { + ToolActivityState::Running => (Status::Pending, "running"), + ToolActivityState::Done => (Status::Evidence, "done"), + ToolActivityState::Error => (Status::Blocked, "error"), + } +} + +fn render_transcript(frame: &mut Frame, app: &App, view: &TranscriptView, area: Rect) { + let presentation = &app.next_presentation; + let appearance = &presentation.appearance; + let width = usize::from(area.width.max(1)); + let mut lines: Vec> = transcript_lines(presentation, view) + .into_iter() + .flat_map(|line| wrap_line(line, width)) + .collect(); + // A scrolled reader gets a notice row at the bottom of the pane. + let notice_rows = usize::from(!app.auto_scroll && area.height > 1); + let height = usize::from(area.height).saturating_sub(notice_rows); + let (start, unseen) = app.next_shell.window( + lines.len(), + height, + area.width, + app.auto_scroll, + app.scroll_offset, + ); + lines.drain(..start); + lines.truncate(height); + if notice_rows > 0 { + lines.resize(height, Line::default()); + let jump = app + .keybindings + .reachable_chord("jumpToLive") + .map(|keys| format!("{keys} jump to live")) + .unwrap_or_else(|| "scroll down to return to live".to_string()); + let separator = primitives::Mark::Separator.glyph(appearance.glyphs); + let notice = if unseen > 0 || app.new_messages_while_scrolled > 0 { + primitives::status_message( + appearance, + Status::Pending, + "new activity below", + &format!("{separator} {jump}"), + ) + } else { + Line::from(Span::styled( + format!("scrolled up {separator} {jump}"), + appearance.style(Role::TextMuted), + )) + }; + lines.extend(wrap_line(notice, width).into_iter().take(1)); + } + frame.render_widget(Paragraph::new(lines), area); +} + +fn render_inspector( + frame: &mut Frame, + app: &App, + view: &TranscriptView, + page: InspectorPage, + area: Rect, +) { + let appearance = &app.next_presentation.appearance; + let width = usize::from(area.width.max(1)); + let separator = primitives::Mark::Separator.glyph(appearance.glyphs); + let mut tabs = Vec::new(); + for candidate in InspectorPage::ALL { + let (mark, role) = if candidate == page { + (primitives::Mark::Selected, Role::Focus) + } else { + (primitives::Mark::Unselected, Role::TextMuted) + }; + tabs.push(Span::styled( + format!("{} {} ", mark.glyph(appearance.glyphs), candidate.title()), + appearance.style(role), + )); + } + let mut header = vec![ + primitives::inspector_title(appearance, page.title()), + Line::from(tabs), + ]; + let body = match page { + InspectorPage::Activity => activity_page(appearance, app, view), + InspectorPage::Session => session_page(appearance, app), + InspectorPage::Views => views_page(appearance, app), + }; + let key = |action: &str| { + app.keybindings + .reachable_chord_in(action, &KeyContext::Inspector) + }; + let footer_text = [ + key("nextInspectorPage").map(|keys| format!("{keys} next page")), + key("closeInspector").map(|keys| format!("{keys} close")), + ] + .into_iter() + .flatten() + .collect::>() + .join(&format!(" {separator} ")); + let footer = if footer_text.is_empty() { + primitives::status_message( + appearance, + Status::Blocked, + "unreachable", + "no key closes the inspector", + ) + } else { + Line::from(Span::styled( + footer_text, + appearance.style(Role::TextSubtle), + )) + }; + + let mut header: Vec> = header + .drain(..) + .flat_map(|line| wrap_line(line, width)) + .collect(); + let footer = wrap_line(footer, width); + let body: Vec> = body + .into_iter() + .flat_map(|line| wrap_line(line, width)) + .collect(); + let height = usize::from(area.height); + // The way back stays visible: the footer outranks body rows. + let footer_rows = footer.len().min(height); + header.truncate(height - footer_rows); + let body_rows = height - footer_rows - header.len(); + let scroll_max = body.len().saturating_sub(body_rows); + app.next_shell.inspector_scroll_max.set(scroll_max); + let scroll = app.next_shell.inspector_scroll.min(scroll_max); + let mut lines = header; + lines.extend(body.into_iter().skip(scroll).take(body_rows)); + lines.resize(height - footer_rows, Line::default()); + lines.extend(footer.into_iter().take(footer_rows)); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines), area); +} + +fn field(appearance: &Appearance, name: &str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{name:<12}"), appearance.style(Role::TextMuted)), + Span::styled(value, appearance.style(Role::Text)), + ]) +} + +fn activity_page(appearance: &Appearance, app: &App, view: &TranscriptView) -> Vec> { + let mut lines = Vec::new(); + if app.cancellation != TurnCancellation::Idle { + lines.push(primitives::status_message( + appearance, + Status::Pending, + "cancelling", + "waiting for the runtime to stop the turn", + )); + } else if view.is_streaming { + lines.push(primitives::status_message( + appearance, + Status::Pending, + "working", + "a response is streaming", + )); + } + if view.tools.is_empty() { + lines.push(Line::from(Span::styled( + "No tool activity in this turn.", + appearance.style(Role::TextMuted), + ))); + } + for tool in &view.tools { + let (status, word) = tool_status(tool.state); + lines.push(primitives::tool_row( + appearance, &tool.name, status, word, false, + )); + } + if !app.queued_messages.is_empty() { + lines.push(field( + appearance, + "Queued", + format!( + "{} message(s) send after this turn", + app.queued_messages.len() + ), + )); + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "Tool detail and diffs arrive with #411.", + appearance.style(Role::TextSubtle), + ))); + lines +} + +fn session_page(appearance: &Appearance, app: &App) -> Vec> { + let project = app + .config + .project_dir + .clone() + .or_else(|| std::env::current_dir().ok()) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "no project".to_string()); + let credentials = if app.has_credentials { + "configured".to_string() + } else { + "not configured; nothing connects until you choose a provider".to_string() + }; + let familiar = match &app.config.familiar { + Some(id) => format!("{id} (display name; identity unverified until #412)"), + None => "none".to_string(), + }; + vec![ + field(appearance, "Project", project), + field( + appearance, + "Session", + app.session_title + .clone() + .unwrap_or_else(|| "untitled session".to_string()), + ), + field( + appearance, + "Model", + model_label(app).unwrap_or_else(|| "not selected".to_string()), + ), + field( + appearance, + "Provider", + app.config + .provider + .clone() + .unwrap_or_else(|| "not selected".to_string()), + ), + field(appearance, "Credentials", credentials), + field(appearance, "Familiar", familiar), + field( + appearance, + "Renderer", + "Next preview; unset COVEN_CODE_UI for Classic".to_string(), + ), + ] +} + +fn views_page(appearance: &Appearance, app: &App) -> Vec> { + let view = |name: &str, detail: &str| { + let mut line = Line::from(Span::styled( + format!(" {name:<10}"), + appearance.style(Role::Text), + )); + line.spans.extend( + primitives::status_message(appearance, Status::Blocked, "unavailable", detail).spans, + ); + line + }; + let mut lines = vec![ + primitives::list_item(appearance, "Chat current", true), + view("Work", "needs a qualified task producer (#413)"), + view("Research", "needs a qualified research lifecycle (#414)"), + view("Memory", "needs a qualified memory reader (#415)"), + Line::default(), + ]; + let palette = app + .keybindings + .reachable_chord("openCommandPalette") + .map(|keys| format!("{keys} command palette, / suggestions")) + .unwrap_or_else(|| "/ suggestions (no key opens the palette)".to_string()); + lines.push(field(appearance, "Commands", palette)); + lines +} + /// Wrap a line to `width` cells, keeping each span's style on the cells it /// covers. fn wrap_line(line: Line<'static>, width: usize) -> Vec> { @@ -260,37 +830,68 @@ fn wrap_line(line: Line<'static>, width: usize) -> Vec> { lines } -fn render_transcript( - frame: &mut Frame, - presentation: &NextPresentation, - view: &TranscriptView, - area: Rect, -) { - let width = usize::from(area.width.max(1)); - let mut lines: Vec> = transcript_lines(presentation, view) - .into_iter() - .flat_map(|line| wrap_line(line, width)) - .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); +/// Rule above the composer; a non-empty draft is labeled as not sent. +fn composer_rule(appearance: &Appearance, view: &TranscriptView, width: u16) -> Line<'static> { + if view.draft.is_empty() && view.draft_attachments.is_empty() { + return primitives::rule(appearance, width); + } + let label = "draft not sent "; + let fill = usize::from(width).saturating_sub(label.len()); + Line::from(vec![ + Span::styled(label, appearance.style(Role::TextMuted)), + Span::styled( + primitives::Mark::Rule.glyph(appearance.glyphs).repeat(fill), + appearance.style(Role::Border), + ), + ]) } -fn status_line(appearance: &Appearance, view: &TranscriptView, frame_count: u64) -> Line<'static> { +fn status_line(appearance: &Appearance, app: &App, view: &TranscriptView) -> Line<'static> { + let separator = primitives::Mark::Separator.glyph(appearance.glyphs); + if app.cancellation != TurnCancellation::Idle { + // Not "Cancelled." until the runtime has actually stopped the turn. + return primitives::status_message( + appearance, + Status::Pending, + "cancelling", + "waiting for the runtime to stop the turn", + ); + } let mut spans = if view.permission.is_some() { primitives::status_label(appearance, Status::Pending, "awaiting permission") + } else if app.voice_recording { + primitives::status_label(appearance, Status::Pending, "recording voice") } else if view.is_streaming { vec![Span::styled( - format!("{} working", primitives::activity(appearance, frame_count)), + format!( + "{} working", + primitives::activity(appearance, app.frame_count) + ), appearance.style(Role::Presence), )] } else { vec![Span::styled("ready", appearance.style(Role::TextMuted))] }; + let running = view + .tools + .iter() + .filter(|tool| tool.state == ToolActivityState::Running) + .count(); + if running > 0 { + spans.push(Span::styled( + format!(" {separator} {running} tool(s) running"), + appearance.style(Role::TextMuted), + )); + } + if !app.queued_messages.is_empty() { + spans.push(Span::styled( + format!(" {separator} {} queued", app.queued_messages.len()), + appearance.style(Role::TextMuted), + )); + } if let Some(status) = &view.status { spans.push(Span::styled( - format!(" · {status}"), + format!(" {separator} {status}"), appearance.style(Role::TextMuted), )); } diff --git a/src-rust/crates/tui/src/next/primitives.rs b/src-rust/crates/tui/src/next/primitives.rs index ad62e37b..92fa0931 100644 --- a/src-rust/crates/tui/src/next/primitives.rs +++ b/src-rust/crates/tui/src/next/primitives.rs @@ -24,6 +24,8 @@ pub enum Mark { /// Inline separator between related items. Separator, Rule, + /// Vertical rule between side-by-side panes. + Divider, Verified, Pending, Blocked, @@ -45,6 +47,7 @@ impl Mark { Mark::Crumb => ("/", "/"), Mark::Separator => ("·", "-"), Mark::Rule => ("─", "-"), + Mark::Divider => ("│", "|"), Mark::Verified => ("✓", "[ok]"), Mark::Pending => ("…", "[..]"), Mark::Blocked => ("✕", "[x]"), diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index efd8292f..b2206cd2 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -833,7 +833,7 @@ pub fn render_app(frame: &mut Frame, app: &App) { /// Snapshot the rendered text of every row inside the selectable area into /// `app.last_row_text` so that subsequent double/triple-clicks can locate /// word and paragraph boundaries (issue #149 follow-up). -fn cache_selectable_row_text(frame: &mut Frame, app: &App) { +pub(crate) fn cache_selectable_row_text(frame: &mut Frame, app: &App) { let selectable_area = app.last_selectable_area.get(); if selectable_area.width == 0 || selectable_area.height == 0 { app.last_row_text.borrow_mut().clear(); @@ -868,7 +868,7 @@ fn cache_selectable_row_text(frame: &mut Frame, app: &App) { /// Post-render pass: invert colours on selected cells and extract the /// selection text into `app.selection_text`. -fn apply_selection_highlight(frame: &mut Frame, app: &App) { +pub(crate) fn apply_selection_highlight(frame: &mut Frame, app: &App) { let (anchor, focus) = match (app.selection_anchor, app.selection_focus) { (Some(a), Some(f)) => (a, f), _ => return, diff --git a/src-rust/crates/tui/tests/next_shell.rs b/src-rust/crates/tui/tests/next_shell.rs new file mode 100644 index 00000000..66d67042 --- /dev/null +++ b/src-rust/crates/tui/tests/next_shell.rs @@ -0,0 +1,640 @@ +//! T3 (#410): the Next transcript/composer shell. +//! +//! Layout rules at the accepted fixture sizes, one inspector that owns focus +//! and returns predictably, reading position under streaming, cancellation +//! that stays pending until the runtime settles it, paste safety, workspace +//! context and text fallbacks. Classic must not observe any of it. + +use claurst_api::streaming::ContentDelta; +use claurst_api::AnthropicStreamEvent; +use claurst_core::config::Config; +use claurst_core::cost::CostTracker; +use claurst_core::types::{ContentBlock, ImageSource, Message}; +use claurst_query::QueryEvent; +use claurst_tui::app::App; +use claurst_tui::application::{ScopedQueryEvent, SessionScope, TurnCancellation, UiRenderer}; +use claurst_tui::image_paste::PastedImage; +use claurst_tui::next::layout::{plan, SizeClass}; +use claurst_tui::next::style::{Appearance, ColorMode, Glyphs}; +use claurst_tui::next::{InspectorPage, NextPresentation}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::backend::{Backend, TestBackend}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::Terminal; + +const FIXTURES: [(u16, u16); 5] = [(20, 6), (40, 12), (80, 24), (120, 40), (180, 50)]; + +fn next_app() -> App { + let config = Config { + model: Some("claude-shell-model".to_string()), + provider: Some("anthropic".to_string()), + project_dir: Some("/work/acme-api".into()), + ..Config::default() + }; + let mut app = App::new(config, CostTracker::new()); + app.has_credentials = true; + app.renderer = UiRenderer::Next; + app.next_presentation = NextPresentation { + appearance: Appearance { + color: ColorMode::NoColor, + glyphs: Glyphs::Ascii, + ..Appearance::default() + }, + ..NextPresentation::default() + }; + app.turn_start = None; + app +} + +fn draw(app: &App, width: u16, height: u16) -> (Buffer, Option<(u16, u16)>) { + // A frame that places no cursor leaves this sentinel untouched. + const UNSET: (u16, u16) = (u16::MAX, u16::MAX); + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .backend_mut() + .set_cursor_position(UNSET) + .expect("cursor"); + terminal + .draw(|frame| claurst_tui::next::render_next(frame, app)) + .expect("draw"); + let cursor = terminal + .backend_mut() + .get_cursor_position() + .ok() + .map(|p| (p.x, p.y)) + .filter(|&position| position != UNSET); + (terminal.backend().buffer().clone(), cursor) +} + +fn text_of(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width.max(1)); + buffer + .content + .chunks(width) + .map(|row| { + row.iter() + .map(|cell| cell.symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} + +fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) +} + +fn alt(c: char) -> KeyEvent { + key(KeyCode::Char(c), KeyModifiers::ALT) +} + +fn esc() -> KeyEvent { + key(KeyCode::Esc, KeyModifiers::NONE) +} + +fn delta(text: &str) -> QueryEvent { + QueryEvent::Stream(AnthropicStreamEvent::ContentBlockDelta { + index: 0, + delta: ContentDelta::TextDelta { + text: text.to_string(), + }, + }) +} + +fn apply(app: &mut App, event: QueryEvent) { + let scope = SessionScope::default(); + scope.apply( + app, + ScopedQueryEvent { + generation: scope.current(), + event, + }, + ); +} + +fn with_history(app: &mut App, count: usize) { + for index in 0..count { + app.push_message(Message::user(format!("question {index:03}"))); + app.push_message(Message::assistant(format!("answer {index:03}"))); + } +} + +#[test] +fn layout_follows_the_accepted_size_rules() { + for (width, height) in FIXTURES { + let area = Rect::new(0, 0, width, height); + let closed = plan(area, false, 0, 2); + assert!(closed.inspector.is_none(), "{width}x{height}"); + assert!(closed.transcript.is_some(), "{width}x{height}"); + assert!(closed.composer.height >= 1, "{width}x{height}"); + + let open = plan(area, true, 0, 2); + let inspector = open.inspector.expect("requested inspector is shown"); + match open.size_class { + SizeClass::Split => { + let transcript = open.transcript.expect("split keeps the transcript"); + assert!(transcript.width >= 72, "{width}x{height}: {transcript:?}"); + assert!(inspector.width >= 40, "{width}x{height}: {inspector:?}"); + assert!(transcript.x + transcript.width < inspector.x, "separator"); + } + SizeClass::Focus | SizeClass::Single => { + assert!( + open.transcript.is_none(), + "inspector replaces the main pane" + ); + assert_eq!(inspector.width, width); + } + } + // Every region stays inside the terminal and the composer survives. + for rect in [ + Some(open.header), + open.transcript, + Some(inspector), + Some(open.composer), + ] + .into_iter() + .flatten() + { + assert!(rect.x + rect.width <= width && rect.y + rect.height <= height); + } + assert!(open.composer.height >= 1); + } + let class = |w, h| plan(Rect::new(0, 0, w, h), false, 0, 2).size_class; + assert_eq!(class(79, 40), SizeClass::Focus); + assert_eq!(class(120, 23), SizeClass::Focus); + assert_eq!(class(80, 24), SizeClass::Single); + assert_eq!(class(119, 40), SizeClass::Single); + assert_eq!(class(120, 31), SizeClass::Single); + assert_eq!(class(120, 32), SizeClass::Split); + assert_eq!(class(180, 50), SizeClass::Split); +} + +#[test] +fn every_fixture_size_draws_without_underflow_and_keeps_the_draft_visible() { + let mut app = next_app(); + with_history(&mut app, 30); + app.set_prompt_text("draft-x".to_string()); + for inspector in [None, Some(InspectorPage::Activity)] { + app.next_shell.inspector = inspector; + for (width, height) in FIXTURES + .into_iter() + .chain([(0, 0), (1, 1), (8, 2), (20, 3)]) + { + let (buffer, _) = draw(&app, width, height); + if width >= 20 && height >= 3 { + let text = text_of(&buffer); + assert!(text.contains("draft-x"), "{width}x{height}:\n{text}"); + } + } + } +} + +#[test] +fn inspector_opens_beside_the_transcript_only_when_both_fit() { + let mut app = next_app(); + with_history(&mut app, 3); + assert!(!app.handle_key_event(alt('i'))); + assert_eq!(app.next_shell.inspector, Some(InspectorPage::Activity)); + + let (wide, _) = draw(&app, 120, 40); + let wide = text_of(&wide); + assert!( + wide.contains("answer 002"), + "transcript stays at 120x40:\n{wide}" + ); + assert!( + wide.contains("Activity"), + "inspector shown at 120x40:\n{wide}" + ); + + let (narrow, _) = draw(&app, 80, 24); + let narrow = text_of(&narrow); + assert!(narrow.contains("Activity"), "{narrow}"); + assert!( + !narrow.contains("answer 002"), + "inspector replaces main at 80x24:\n{narrow}" + ); + assert!(narrow.contains("Esc"), "the way back is shown:\n{narrow}"); +} + +#[test] +fn escape_closes_the_inspector_first_and_preserves_draft_cursor_and_position() { + let mut app = next_app(); + with_history(&mut app, 20); + app.set_prompt_text("half written".to_string()); + app.prompt_input.cursor = 4; + app.scroll_offset = 7; + app.auto_scroll = false; + app.is_streaming = true; + + app.handle_key_event(alt('i')); + app.handle_key_event(key(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.next_shell.inspector, Some(InspectorPage::Session)); + app.handle_key_event(esc()); + assert_eq!(app.next_shell.inspector, None, "Esc closes the inspector"); + assert!( + app.is_streaming, + "Esc that closes the inspector does not cancel" + ); + assert_eq!(app.cancellation, TurnCancellation::Idle); + assert_eq!(app.prompt_input.text, "half written"); + assert_eq!(app.prompt_input.cursor, 4); + assert_eq!((app.scroll_offset, app.auto_scroll), (7, false)); + + // With nothing open, Esc asks the runtime to cancel the turn. + app.handle_key_event(esc()); + assert_eq!(app.cancellation, TurnCancellation::Requested); +} + +#[test] +fn classic_does_not_observe_next_only_keys() { + let mut app = next_app(); + app.renderer = UiRenderer::Classic; + app.set_prompt_text("abc".to_string()); + app.handle_key_event(alt('i')); + app.handle_key_event(alt('l')); + assert_eq!(app.next_shell.inspector, None); + assert!(app.auto_scroll); + // Same as before #410: the chords fall through to text input + // (verified against the #409 head a65df294). + assert_eq!(app.prompt_input.text, "abcil"); +} + +#[test] +fn escape_and_interrupt_request_runtime_cancellation_and_next_shows_it_pending() { + for (renderer, cancel_key) in [ + (UiRenderer::Next, esc()), + ( + UiRenderer::Next, + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + ), + ] { + let mut app = next_app(); + app.renderer = renderer; + app.push_message(Message::user("run it".to_string())); + apply(&mut app, delta("working on it")); + assert!(app.is_streaming); + app.handle_key_event(cancel_key); + assert!( + app.take_cancellation_request(), + "{renderer:?} {cancel_key:?}" + ); + assert!(!app.take_cancellation_request(), "a request is taken once"); + assert_eq!(app.cancellation, TurnCancellation::AwaitingRuntime); + if renderer == UiRenderer::Next { + let text = text_of(&draw(&app, 80, 24).0); + assert!(text.contains("cancelling"), "{text}"); + assert!(!text.contains("Cancelled."), "not confirmed yet:\n{text}"); + } + app.settle_cancellation(); + assert_eq!(app.cancellation, TurnCancellation::Idle); + if renderer == UiRenderer::Next { + let text = text_of(&draw(&app, 80, 24).0); + assert!(!text.contains("cancelling"), "{text}"); + } + } + // Classic keeps its existing cancel handling: no runtime request, and + // nothing for the loop to settle. + for cancel_key in [esc(), key(KeyCode::Char('c'), KeyModifiers::CONTROL)] { + let mut classic = next_app(); + classic.renderer = UiRenderer::Classic; + classic.push_message(Message::user("run it".to_string())); + apply(&mut classic, delta("working on it")); + classic.handle_key_event(cancel_key); + assert!(!classic.is_streaming); + assert!(!classic.take_cancellation_request(), "{cancel_key:?}"); + assert_eq!(classic.cancellation, TurnCancellation::Idle); + } + // Esc while idle does not invent a cancellation. + let mut idle = next_app(); + idle.handle_key_event(esc()); + assert!(!idle.take_cancellation_request()); +} + +#[test] +fn a_scrolled_reader_keeps_position_while_output_streams_and_can_jump_to_live() { + let mut app = next_app(); + with_history(&mut app, 40); + app.handle_key_event(key(KeyCode::PageUp, KeyModifiers::NONE)); + assert!(!app.auto_scroll); + let (before, _) = draw(&app, 80, 24); + let before = text_of(&before); + let anchor_line = before + .lines() + .find(|line| line.starts_with("answer ")) + .expect("history visible") + .to_string(); + assert!(!before.contains("new activity"), "{before}"); + + app.push_message(Message::user("follow-up".to_string())); + apply(&mut app, delta(&"streamed line\n".repeat(30))); + apply( + &mut app, + QueryEvent::ToolStart { + tool_name: "Read".to_string(), + tool_id: "toolu-1".to_string(), + input_json: "{}".to_string(), + }, + ); + for _ in 0..3 { + let (after, _) = draw(&app, 80, 24); + app.settle_scroll_anchor(); + let after = text_of(&after); + assert!( + after.contains(&anchor_line), + "reading position kept:\n{after}" + ); + assert!(after.contains("new activity"), "{after}"); + assert!(after.contains("Alt+L"), "jump key shown:\n{after}"); + assert!(!after.contains("streamed line"), "{after}"); + } + assert_eq!( + app.next_shell.inspector, None, + "a tool start never steals focus" + ); + + app.handle_key_event(alt('l')); + assert!(app.auto_scroll); + let (live, _) = draw(&app, 80, 24); + let live = text_of(&live); + assert!(live.contains("streamed line"), "{live}"); + assert!(!live.contains("new activity"), "{live}"); +} + +#[test] +fn pasted_newlines_and_commands_stay_an_unsent_draft() { + let mut app = next_app(); + app.handle_paste_data("echo one\n/clear".to_string()); + assert!(app.messages.is_empty(), "paste never submits"); + assert!(app.prompt_input.text.contains("/clear")); + let text = text_of(&draw(&app, 80, 24).0); + assert!(text.contains("/clear"), "{text}"); + assert!( + text.contains("not sent"), + "draft is visibly unsent:\n{text}" + ); + + let mut large = next_app(); + large.handle_paste_data((0..200).map(|n| format!("line {n}\n")).collect()); + assert!(large.messages.is_empty()); + let text = text_of(&draw(&large, 80, 24).0); + assert!( + text.contains("[Pasted ~200 lines #1]"), + "large paste is summarized:\n{text}" + ); +} + +#[test] +fn workspace_line_names_project_session_and_model_without_claiming_identity() { + let mut app = next_app(); + app.session_title = Some("Fix billing export".to_string()); + app.model_name = "claude-shell-model".to_string(); + app.config.familiar = Some("nyx".to_string()); + let text = text_of(&draw(&app, 120, 40).0); + let header = text.lines().next().unwrap_or_default(); + assert!(header.contains("acme-api"), "{header}"); + assert!(header.contains("Fix billing export"), "{header}"); + assert!(header.contains("claude-shell-model"), "{header}"); + assert!(header.contains("nyx"), "{header}"); + assert!( + header.contains("unverified"), + "display name is not identity: {header}" + ); + + // Narrow terminals keep the project and drop optional context first. + let narrow = text_of(&draw(&app, 40, 12).0); + let header = narrow.lines().next().unwrap_or_default(); + assert!(header.contains("acme-api"), "{header}"); +} + +#[test] +fn the_terminal_cursor_sits_in_the_composer_for_ime_and_unicode_input() { + let mut app = next_app(); + app.set_prompt_text("héllo 漢".to_string()); + let (buffer, cursor) = draw(&app, 80, 24); + let (x, y) = cursor.expect("composer shows the terminal cursor"); + let row = text_of(&buffer) + .lines() + .nth(usize::from(y)) + .unwrap_or_default() + .to_string(); + assert!( + row.contains("héllo 漢"), + "cursor row is the draft row: {row}" + ); + // "> " prefix is 2 cells, then 6 + 2 cells of text. + assert_eq!(x, 2 + 6 + 2); + + app.prompt_input.cursor = 1; + let (_, cursor) = draw(&app, 80, 24); + assert_eq!(cursor.map(|c| c.0), Some(3)); +} + +#[test] +fn images_fall_back_to_filename_and_type_text() { + let mut app = next_app(); + app.prompt_input.add_image(PastedImage { + path: "/tmp/shot.png".into(), + label: "shot.png".to_string(), + dimensions: Some((640, 480)), + }); + app.push_message(Message::user_blocks(vec![ + ContentBlock::Text { + text: "see attached".to_string(), + }, + ContentBlock::Image { + source: ImageSource { + source_type: "base64".to_string(), + media_type: Some("image/png".to_string()), + data: Some("AAAA".to_string()), + url: None, + }, + }, + ])); + let text = text_of(&draw(&app, 80, 24).0); + assert!(text.contains("[image shot.png 640x480]"), "{text}"); + assert!(text.contains("[image image/png]"), "{text}"); +} + +#[test] +fn unavailable_views_explain_their_blocker_instead_of_faking_integrations() { + let mut app = next_app(); + app.next_shell.inspector = Some(InspectorPage::Views); + let text = text_of(&draw(&app, 80, 24).0); + for (view, issue) in [("Work", "#413"), ("Research", "#414"), ("Memory", "#415")] { + let line = text + .lines() + .find(|line| line.contains(view)) + .unwrap_or_else(|| panic!("{view} missing:\n{text}")); + assert!(line.contains("unavailable"), "{line}"); + assert!(line.contains(issue), "{line}"); + } + assert!( + text.contains("Ctrl+K"), + "commands stay discoverable:\n{text}" + ); +} + +#[test] +fn transcript_text_is_selectable_in_next() { + let mut app = next_app(); + with_history(&mut app, 2); + let _ = draw(&app, 80, 24); + let area = app.last_selectable_area.get(); + assert!(area.width > 0 && area.height > 0, "selection has a target"); + let rows = app.last_row_text.borrow(); + assert!(rows.values().any(|row| row.contains("answer 001"))); +} + +fn row_of(text: &str, needle: &str) -> Option { + text.lines().position(|line| line.contains(needle)) +} + +#[test] +fn the_reading_row_stays_fixed_while_rows_arrive_and_disappear_below() { + let mut app = next_app(); + with_history(&mut app, 40); + app.handle_key_event(key(KeyCode::PageUp, KeyModifiers::NONE)); + let first = text_of(&draw(&app, 80, 24).0); + app.settle_scroll_anchor(); + let anchor = first + .lines() + .find(|line| line.starts_with("answer ")) + .expect("history visible") + .to_string(); + let at = row_of(&first, &anchor); + + apply(&mut app, delta(&"streamed line\n".repeat(30))); + let grown = text_of(&draw(&app, 80, 24).0); + app.settle_scroll_anchor(); + assert_eq!( + row_of(&grown, &anchor), + at, + "growth moved the reader:\n{grown}" + ); + + // Cancelling clears the live text: rows vanish below the reader. + app.streaming_text.clear(); + let shrunk = text_of(&draw(&app, 80, 24).0); + app.settle_scroll_anchor(); + assert_eq!( + row_of(&shrunk, &anchor), + at, + "shrinking moved the reader:\n{shrunk}" + ); +} + +#[test] +fn rewrapping_keeps_the_reader_near_their_text_and_over_scroll_is_clamped() { + let mut app = next_app(); + with_history(&mut app, 60); + app.scroll_offset = 40; + app.auto_scroll = false; + let wide = text_of(&draw(&app, 120, 40).0); + app.settle_scroll_anchor(); + let anchor = wide + .lines() + .find(|line| line.starts_with("answer ")) + .expect("history") + .to_string(); + // Opening the inspector in Split narrows the transcript; closing it + // widens it again. The reader stays on the same stretch of history. + app.handle_key_event(alt('i')); + let narrow = text_of(&draw(&app, 120, 40).0); + app.settle_scroll_anchor(); + assert!(narrow.contains(&anchor), "{narrow}"); + app.handle_key_event(alt('i')); + let back = text_of(&draw(&app, 120, 40).0); + app.settle_scroll_anchor(); + assert!(back.contains(&anchor), "{back}"); + + // An offset past the top is clamped, so the next step down moves. + app.scroll_offset = 100_000; + let top = text_of(&draw(&app, 80, 24).0); + app.settle_scroll_anchor(); + assert!( + app.scroll_offset < 100_000, + "over-scroll settled to the range" + ); + app.handle_key_event(key(KeyCode::PageDown, KeyModifiers::NONE)); + let moved = text_of(&draw(&app, 80, 24).0); + assert_ne!( + top, moved, + "the first PageDown after over-scroll takes effect" + ); +} + +#[test] +fn the_cursor_row_stays_visible_and_exact_in_long_wrapped_and_wide_drafts() { + let mut app = next_app(); + let lines: Vec = (0..12).map(|n| format!("line-{n:02}")).collect(); + app.set_prompt_text(lines.join("\n")); + // Cursor at the end of line-02. + app.prompt_input.cursor = lines[..3].iter().map(|l| l.len() + 1).sum::() - 1; + let (buffer, cursor) = draw(&app, 80, 24); + let (x, y) = cursor.expect("cursor visible in a draft taller than the composer"); + let row = text_of(&buffer) + .lines() + .nth(usize::from(y)) + .unwrap_or_default() + .to_string(); + assert!( + row.contains("line-02"), + "cursor row is the edited line: {row}" + ); + assert_eq!(x, 2 + 7); + + // A wide character that would cross the edge wraps early; the cursor + // on the next logical line must account for that extra row. + app.set_prompt_text(format!("a{}b\nxyz", "漢".repeat(78))); + let (buffer, cursor) = draw(&app, 80, 24); + let (x, y) = cursor.expect("cursor"); + let row = text_of(&buffer) + .lines() + .nth(usize::from(y)) + .unwrap_or_default() + .to_string(); + assert!(row.contains("xyz"), "cursor row: {row}"); + assert_eq!(x, 2 + 3); +} + +#[test] +fn a_settled_cancellation_reports_its_outcome_and_remote_cancels_wait_too() { + let mut app = next_app(); + apply(&mut app, delta("working")); + app.handle_key_event(esc()); + assert!(app.take_cancellation_request()); + // The loop clears the status when the worker finishes, then settles. + app.status_message = None; + app.settle_cancellation(); + let text = text_of(&draw(&app, 80, 24).0); + assert!(text.contains("Cancelled."), "{text}"); + + let mut remote = next_app(); + apply(&mut remote, delta("working")); + remote.note_cancellation_sent(); + let text = text_of(&draw(&remote, 80, 24).0); + assert!(text.contains("cancelling"), "{text}"); +} + +#[test] +fn inspector_scrolling_is_bounded_and_voice_recording_is_visible() { + let mut app = next_app(); + app.handle_key_event(alt('i')); + let _ = draw(&app, 120, 40); + for _ in 0..10 { + app.handle_key_event(key(KeyCode::PageDown, KeyModifiers::NONE)); + } + assert_eq!( + app.next_shell.inspector_scroll, 0, + "a short page does not scroll" + ); + + let mut voice = next_app(); + voice.voice_recording = true; + let text = text_of(&draw(&voice, 80, 24).0); + assert!(text.contains("recording voice"), "{text}"); +} diff --git a/src-rust/crates/tui/tests/presentation_parity.rs b/src-rust/crates/tui/tests/presentation_parity.rs index 11e27b5d..637cb8bd 100644 --- a/src-rust/crates/tui/tests/presentation_parity.rs +++ b/src-rust/crates/tui/tests/presentation_parity.rs @@ -251,19 +251,29 @@ fn next_hands_every_input_owning_surface_to_classic() { } #[test] -fn next_clears_classic_mouse_geometry() { +fn next_replaces_classic_mouse_geometry_with_its_own_transcript_target() { let (app, _) = replay(trace()); draw(&app, UiRenderer::Classic, 120, 40); assert!( app.last_input_area.get().area() > 0, "classic records geometry" ); + let classic_selectable = app.last_selectable_area.get(); draw(&app, UiRenderer::Next, 120, 40); + // No per-message hit-test survives from Classic's layout. assert!(app.message_row_map.borrow().is_empty()); - assert!(app.last_row_text.borrow().is_empty()); + assert!(app.thinking_row_map.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); + // Selection and copy target the Next transcript pane only (#410). + let selectable = app.last_selectable_area.get(); + assert!(selectable.area() > 0); + assert_ne!(selectable, classic_selectable); + assert_eq!(app.last_msg_area.get(), selectable); + assert!(app + .last_row_text + .borrow() + .keys() + .all(|row| *row >= selectable.y && *row < selectable.y + selectable.height)); } #[test]