From 0f07d537ee100b864eb95313187b89ca35192c72 Mon Sep 17 00:00:00 2001 From: Kefei Qian Date: Tue, 14 Jul 2026 16:13:01 +0800 Subject: [PATCH 01/52] feat: add v0.2.0 bootstrap foundation Extract the first review branch from the frozen release candidate with the streaming-ready backend protocol, git-status backend/TUI seam, prompt queue streaming state, and composer scroll support. Keep provider configuration out of this bootstrap branch: submit returns needsConfiguration without reading plaintext credentials, leaving provider setup for its dedicated queue item. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c127c9c-ac00-4a90-be0f-4357720a6a8d --- src/backend.rs | 110 +++++++--- src/git.rs | 147 +++++++++++++ src/git/tests.rs | 78 +++++++ src/lib.rs | 1 + src/protocol.rs | 88 ++++++-- tests/git_status.rs | 38 ++++ tests/message_submit.rs | 46 +++- tui/src/__tests__/App.submit.test.tsx | 150 ++++++++++---- .../__tests__/components/HomeScreen.test.tsx | 7 +- .../components/PromptComposer.test.tsx | 87 +++++++- .../client/__tests__/backendClient.test.ts | 74 +++++-- .../__tests__/packagedBackendClient.test.ts | 4 +- tui/src/backend/client/backendClient.ts | 34 ++- .../backend/client/messageConnectionClient.ts | 118 ++++++++++- .../__tests__/materializeBackend.test.ts | 21 +- tui/src/backend/packaged/backendCacheDir.ts | 6 +- .../process/__tests__/backendProcess.test.ts | 54 +++-- .../__tests__/messageProtocol.test.ts | 135 ++++++++++-- tui/src/backend/protocol/messageProtocol.ts | 38 +++- .../runtime/__tests__/backendRuntime.test.ts | 10 +- tui/src/backend/runtime/backendRuntime.ts | 5 + .../components/HomeScreen/HomeScreenView.tsx | 77 ++++++- .../HomeScreen/__tests__/wheelRouting.test.ts | 43 ++++ .../HomeScreen/useCaretScrollSuppression.ts | 36 ++++ tui/src/components/HomeScreen/wheelRouting.ts | 34 +++ tui/src/components/PromptComposer/index.tsx | 60 ++++-- .../PromptComposer/input/handleCursorMove.ts | 18 +- .../PromptComposer/promptTextView.ts | 88 +------- tui/src/constants/backend.ts | 9 + tui/src/constants/ui.ts | 16 ++ tui/src/contracts/backend/client.ts | 38 +++- tui/src/contracts/backend/messages.ts | 80 ++++++- .../composer/__tests__/composerWindow.test.ts | 169 +++++++++++++++ .../composer/__tests__/wrapPromptText.test.ts | 36 ++++ tui/src/libs/composer/composerWindow.ts | 196 ++++++++++++++++++ tui/src/libs/composer/wrapPromptText.ts | 80 +++++++ tui/src/libs/git/__tests__/gitStatus.test.ts | 23 -- tui/src/libs/git/gitStatus.ts | 103 --------- .../promptQueue/__tests__/promptQueue.test.ts | 52 +++++ .../__tests__/streamCoalescer.test.ts | 71 +++++++ tui/src/libs/promptQueue/promptQueue.ts | 94 ++++++++- tui/src/libs/promptQueue/streamCoalescer.ts | 63 ++++++ tui/src/libs/terminal/__tests__/mouse.test.ts | 63 ++++++ tui/src/libs/terminal/mouse.ts | 58 +++++- tui/src/libs/tui/__tests__/bodyRows.test.ts | 32 +++ tui/src/libs/tui/__tests__/layout.test.ts | 30 +++ tui/src/libs/tui/bodyRows.ts | 38 ++++ tui/src/libs/tui/layout.ts | 18 +- tui/src/state/global/backend.ts | 2 +- tui/src/state/promptQueue/atoms.ts | 106 +++++++--- tui/src/state/promptQueue/store.ts | 17 ++ .../state/ui/__tests__/composerScroll.test.ts | 65 ++++++ tui/src/state/ui/__tests__/gitStatus.test.ts | 53 +++++ tui/src/state/ui/atoms.ts | 65 +++++- .../state/ui/composer/__tests__/atoms.test.ts | 82 +++++++- tui/src/state/ui/composer/atoms.ts | 54 +++++ tui/src/state/ui/gitStatus.ts | 33 ++- tui/vitest.config.ts | 8 +- 58 files changed, 2864 insertions(+), 497 deletions(-) create mode 100644 src/git.rs create mode 100644 src/git/tests.rs create mode 100644 tests/git_status.rs create mode 100644 tui/src/components/HomeScreen/__tests__/wheelRouting.test.ts create mode 100644 tui/src/components/HomeScreen/useCaretScrollSuppression.ts create mode 100644 tui/src/components/HomeScreen/wheelRouting.ts create mode 100644 tui/src/libs/composer/__tests__/composerWindow.test.ts create mode 100644 tui/src/libs/composer/__tests__/wrapPromptText.test.ts create mode 100644 tui/src/libs/composer/composerWindow.ts create mode 100644 tui/src/libs/composer/wrapPromptText.ts delete mode 100644 tui/src/libs/git/__tests__/gitStatus.test.ts delete mode 100644 tui/src/libs/git/gitStatus.ts create mode 100644 tui/src/libs/promptQueue/__tests__/promptQueue.test.ts create mode 100644 tui/src/libs/promptQueue/__tests__/streamCoalescer.test.ts create mode 100644 tui/src/libs/promptQueue/streamCoalescer.ts create mode 100644 tui/src/libs/terminal/__tests__/mouse.test.ts create mode 100644 tui/src/state/promptQueue/store.ts create mode 100644 tui/src/state/ui/__tests__/composerScroll.test.ts create mode 100644 tui/src/state/ui/__tests__/gitStatus.test.ts diff --git a/src/backend.rs b/src/backend.rs index c489e029..1604c340 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,10 +1,11 @@ -use std::{error::Error, fmt}; +use std::{error::Error, fmt, thread}; use lsp_server::{Connection, Message, Notification, Request, Response}; +use crate::git; use crate::protocol::{ - BACKEND_READY_METHOD, JSON_RPC_INVALID_PARAMS, JSON_RPC_METHOD_NOT_FOUND, MessageSubmitParams, - MessageSubmitResult, RpcMethod, + BACKEND_READY_METHOD, GitStatusResult, JSON_RPC_INVALID_PARAMS, JSON_RPC_METHOD_NOT_FOUND, + MessageSubmitParams, MessageSubmitResult, RpcMethod, SUBMIT_STATUS_NEEDS_CONFIGURATION, }; #[derive(Debug)] @@ -28,6 +29,11 @@ impl Error for BackendError {} /// transport is established and before any request is handled, so clients bound /// startup on real JSON-RPC readiness rather than the OS process-spawn event. /// +/// After the receive loop ends (stdin closed), [`io_threads.join`] blocks until +/// every `connection.sender` clone is dropped. Deferred handlers such as +/// [`spawn_git_status`] hold such clones, so an in-flight response is flushed to +/// stdout before the process exits rather than being cut off. +/// /// # Errors /// /// Returns an error when the ready signal cannot be sent, the transport threads @@ -69,15 +75,9 @@ fn run_loop(connection: Connection) -> Result<(), BackendError> { while let Ok(message) = connection.receiver.recv() { match message { Message::Request(request) => { - let response = handle_request(request); - connection - .sender - .send(Message::Response(response)) - .map_err(|error| { - BackendError::Transport(format!( - "failed to write JSON-RPC response: {error}" - )) - })?; + if let Some(response) = handle_request(request, &connection) { + send_response(&connection, response)?; + } } Message::Notification(_) => {} Message::Response(_) => { @@ -91,21 +91,83 @@ fn run_loop(connection: Connection) -> Result<(), BackendError> { Ok(()) } -fn handle_request(request: Request) -> Response { - if request.method != RpcMethod::MessageSubmit.as_str() { - return Response::new_err( +/// Writes one response over the transport, mapping a closed writer to a +/// [`BackendError::Transport`]. +fn send_response(connection: &Connection, response: Response) -> Result<(), BackendError> { + connection + .sender + .send(Message::Response(response)) + .map_err(|error| { + BackendError::Transport(format!("failed to write JSON-RPC response: {error}")) + }) +} + +/// Dispatches one JSON-RPC request. +/// +/// Returns `Some(response)` to answer synchronously, or `None` when the handler +/// owns its response and will send it later. `kqode.message.submit` answers +/// immediately with a configuration-required ack in this bootstrap slice; +/// `kqode.git.status` runs on a spawned thread and sends its response deferred, +/// so a slow `git` never stalls the receive loop. +fn handle_request(request: Request, connection: &Connection) -> Option { + match RpcMethod::from_method(&request.method) { + Some(RpcMethod::MessageSubmit) => Some(handle_message_submit(request, connection)), + Some(RpcMethod::GitStatus) => { + spawn_git_status(request, connection); + None + } + None => Some(Response::new_err( request.id, JSON_RPC_METHOD_NOT_FOUND, format!("unsupported method `{}`", request.method), - ); + )), } +} - match serde_json::from_value::(request.params) { - Ok(params) => Response::new_ok(request.id, MessageSubmitResult::from(params)), - Err(error) => Response::new_err( - request.id, - JSON_RPC_INVALID_PARAMS, - format!("invalid message submit params: {error}"), - ), - } +/// Handles `kqode.message.submit`. +/// +/// Provider configuration lands in a later queue item, so this bootstrap handler +/// accepts the streaming-ready wire shape but never reads plaintext credentials. +fn handle_message_submit(request: Request, _connection: &Connection) -> Response { + let params = match serde_json::from_value::(request.params) { + Ok(params) => params, + Err(error) => { + return Response::new_err( + request.id, + JSON_RPC_INVALID_PARAMS, + format!("invalid message submit params: {error}"), + ); + } + }; + + let MessageSubmitParams { turn_id, .. } = params; + + Response::new_ok( + request.id, + MessageSubmitResult { + turn_id, + status: SUBMIT_STATUS_NEEDS_CONFIGURATION, + }, + ) +} + +/// Spawns a detached thread that computes the workspace git label and sends the +/// deferred response for `request`. +/// +/// Running `git` off the receive loop keeps a slow or hung call from stalling +/// other requests; the thread's `sender` clone also keeps the transport alive +/// until the response is flushed, so the answer is not lost if stdin closes +/// first (see the shutdown note in [`run_stdio`]). +fn spawn_git_status(request: Request, connection: &Connection) { + let id = request.id; + let sender = connection.sender.clone(); + thread::spawn(move || { + let response = Response::new_ok( + id, + GitStatusResult { + label: git::status_label(), + }, + ); + let _ = sender.send(Message::Response(response)); + }); } diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 00000000..c696c6b0 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,147 @@ +//! Git working-tree status for the current workspace. +//! +//! The backend runs in the workspace directory (the TUI spawns it with +//! `cwd = workspaceCwd`), so `git status` is invoked in the inherited process +//! cwd — no path argument is threaded through the protocol. Parsing and label +//! formatting live here in the core runtime so the headless CLI and the TUI show +//! the same string; the TUI only renders whatever label this returns. + +use std::process::{Command, Stdio}; + +/// Branch glyph prefixing every status label. +const GIT_BRANCH_ICON: &str = "⎇"; +/// Flag appended when the worktree has unstaged changes. +const UNSTAGED_CHANGE_FLAG: &str = "*"; +/// Flag appended when the index has staged changes. +const STAGED_CHANGE_FLAG: &str = "+"; +/// Flag appended when the worktree has untracked files. +const UNTRACKED_CHANGE_FLAG: &str = "%"; +/// Prefix of the porcelain `--branch` header line. +const STATUS_BRANCH_PREFIX: &str = "## "; +/// Separator between the local branch and its upstream in the header line. +const STATUS_UPSTREAM_SEPARATOR: &str = "..."; +/// Header text for a repository with no commits yet (`## No commits yet on X`). +const NO_COMMITS_BRANCH_PREFIX: &str = "No commits yet on "; +/// Header text emitted for a detached HEAD. +const DETACHED_HEAD_STATUS: &str = "HEAD (no branch)"; +/// Parsed porcelain status of the workspace worktree. +#[derive(Debug, Eq, PartialEq)] +struct GitStatus { + branch: String, + has_unstaged_changes: bool, + has_staged_changes: bool, + has_untracked_changes: bool, +} + +/// Returns the formatted git status label for the workspace, or `None` when the +/// directory is not a git repository or `git` is unavailable. Blocking; call it +/// off the backend's request loop (e.g. on a thread). +#[must_use] +pub fn status_label() -> Option { + let output = Command::new("git") + .args(["status", "--porcelain=v1", "--branch"]) + .stdin(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + parse_status_label(&String::from_utf8_lossy(&output.stdout)) +} + +/// Formats the porcelain `git status --branch` output into a display label, or +/// `None` when no branch header line is present. +fn parse_status_label(porcelain: &str) -> Option { + parse_status(porcelain).map(|status| format_label(&status)) +} + +fn parse_status(porcelain: &str) -> Option { + let lines: Vec<&str> = porcelain.lines().filter(|line| !line.is_empty()).collect(); + let branch_line = lines + .iter() + .find(|line| line.starts_with(STATUS_BRANCH_PREFIX))?; + let branch = parse_branch_name(branch_line); + + let mut status = GitStatus { + branch, + has_unstaged_changes: false, + has_staged_changes: false, + has_untracked_changes: false, + }; + + for line in &lines { + if line.starts_with(STATUS_BRANCH_PREFIX) { + continue; + } + let code = line.as_bytes(); + if let Some(&staged) = code.first() + && is_change_flag(staged) + { + status.has_staged_changes = true; + } + if let Some(&unstaged) = code.get(1) + && is_change_flag(unstaged) + { + status.has_unstaged_changes = true; + } + if line.starts_with("??") { + status.has_untracked_changes = true; + } + } + + Some(status) +} + +/// Whether a porcelain XY status byte marks a tracked change — i.e. not clean +/// (` `), untracked (`?`), or ignored (`!`). +fn is_change_flag(code: u8) -> bool { + code != b' ' && code != b'?' && code != b'!' +} + +/// Extracts the branch name from the porcelain `## ...` header line. +fn parse_branch_name(branch_line: &str) -> String { + let branch_status = &branch_line[STATUS_BRANCH_PREFIX.len()..]; + + if let Some(rest) = branch_status.strip_prefix(NO_COMMITS_BRANCH_PREFIX) { + return rest.to_owned(); + } + if branch_status == DETACHED_HEAD_STATUS { + return "HEAD".to_owned(); + } + + let without_upstream = branch_status + .split(STATUS_UPSTREAM_SEPARATOR) + .next() + .unwrap_or(branch_status); + without_upstream + .split(" [") + .next() + .unwrap_or(without_upstream) + .to_owned() +} + +fn format_label(status: &GitStatus) -> String { + format!( + "{GIT_BRANCH_ICON} {}{}", + status.branch, + format_flags(status) + ) +} + +fn format_flags(status: &GitStatus) -> String { + let mut flags = String::new(); + if status.has_unstaged_changes { + flags.push_str(UNSTAGED_CHANGE_FLAG); + } + if status.has_staged_changes { + flags.push_str(STAGED_CHANGE_FLAG); + } + if status.has_untracked_changes { + flags.push_str(UNTRACKED_CHANGE_FLAG); + } + flags +} + +#[cfg(test)] +mod tests; diff --git a/src/git/tests.rs b/src/git/tests.rs new file mode 100644 index 00000000..51e67ca6 --- /dev/null +++ b/src/git/tests.rs @@ -0,0 +1,78 @@ +use super::{format_label, parse_status, parse_status_label}; + +#[test] +fn formats_branch_with_staged_unstaged_and_untracked_flags() { + let label = parse_status_label( + &[ + "## feat/first-ink-tui-jsonrpc-backend...origin/feat/first-ink-tui-jsonrpc-backend", + " M tui/src/App.tsx", + "A src/git.rs", + "?? src/git/tests.rs", + ] + .join("\n"), + ); + + assert_eq!( + label.as_deref(), + Some("⎇ feat/first-ink-tui-jsonrpc-backend*+%") + ); +} + +#[test] +fn formats_a_clean_branch_without_flags() { + assert_eq!( + parse_status_label("## main...origin/main\n").as_deref(), + Some("⎇ main") + ); +} + +#[test] +fn strips_upstream_and_ahead_behind_from_the_branch_name() { + assert_eq!( + parse_status_label("## main...origin/main [ahead 1, behind 2]\n").as_deref(), + Some("⎇ main") + ); +} + +#[test] +fn labels_a_detached_head() { + assert_eq!( + parse_status_label("## HEAD (no branch)\n").as_deref(), + Some("⎇ HEAD") + ); +} + +#[test] +fn labels_a_repository_with_no_commits_yet() { + assert_eq!( + parse_status_label("## No commits yet on main\n").as_deref(), + Some("⎇ main") + ); +} + +#[test] +fn separates_staged_from_unstaged_changes() { + let staged_only = parse_status("## main\nM src/git.rs\n").unwrap(); + assert!(staged_only.has_staged_changes); + assert!(!staged_only.has_unstaged_changes); + assert!(!staged_only.has_untracked_changes); + + let unstaged_only = parse_status("## main\n M src/git.rs\n").unwrap(); + assert!(unstaged_only.has_unstaged_changes); + assert!(!unstaged_only.has_staged_changes); +} + +#[test] +fn treats_untracked_entries_as_untracked_only() { + let status = parse_status("## main\n?? src/new.rs\n").unwrap(); + assert!(status.has_untracked_changes); + assert!(!status.has_staged_changes); + assert!(!status.has_unstaged_changes); + assert_eq!(format_label(&status), "⎇ main%"); +} + +#[test] +fn returns_none_without_a_branch_header_line() { + assert!(parse_status_label(" M src/git.rs\n").is_none()); + assert!(parse_status_label("").is_none()); +} diff --git a/src/lib.rs b/src/lib.rs index 87fac3f6..49c47ffb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod backend; pub mod build_env; +pub mod git; pub mod protocol; diff --git a/src/protocol.rs b/src/protocol.rs index acdc186e..5179a4ba 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -3,9 +3,6 @@ use serde::{Deserialize, Serialize}; /// Hidden argument that starts the internal JSON-RPC backend loop. pub const BACKEND_MODE_ARG: &str = "--__kqode-json-rpc-backend"; -/// ACK text returned by the first-slice backend proof. -pub const ACK_MESSAGE: &str = "ACK: message received"; - /// JSON-RPC notification the backend emits exactly once, immediately after its /// stdio transport is live and before it handles any request. /// @@ -16,6 +13,24 @@ pub const ACK_MESSAGE: &str = "ACK: message received"; /// `tui/src/contracts/backend/messages.ts` (`BACKEND_READY_METHOD`). pub const BACKEND_READY_METHOD: &str = "kqode.backend.ready"; +/// Server→client notification carrying one chunk of streamed assistant text. +/// Mirrored in `tui/src/contracts/backend/messages.ts`. +pub const TOKEN_DELTA_METHOD: &str = "kqode/tokenDelta"; + +/// Server→client notification marking the natural end of a streamed turn. +/// Mirrored in `tui/src/contracts/backend/messages.ts`. +pub const TURN_END_METHOD: &str = "kqode/turnEnd"; + +/// Server→client notification for a turn that ended with a provider/network +/// error. Mirrored in `tui/src/contracts/backend/messages.ts`. +pub const TURN_ERROR_METHOD: &str = "kqode/turnError"; + +/// `status` value when a submit is accepted and streaming has begun. +pub const SUBMIT_STATUS_STREAMING: &str = "streaming"; + +/// `status` value when a submit cannot run because no API key is configured. +pub const SUBMIT_STATUS_NEEDS_CONFIGURATION: &str = "needsConfiguration"; + /// JSON-RPC code for method lookup failures. pub const JSON_RPC_METHOD_NOT_FOUND: i32 = -32601; @@ -26,36 +41,81 @@ pub const JSON_RPC_INVALID_PARAMS: i32 = -32602; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RpcMethod { MessageSubmit, + GitStatus, } impl RpcMethod { pub const fn as_str(self) -> &'static str { match self { Self::MessageSubmit => "kqode.message.submit", + Self::GitStatus => "kqode.git.status", } } + + /// Resolves a wire method name to its [`RpcMethod`], or `None` when the + /// backend does not implement it (yielding a method-not-found response). + #[must_use] + pub fn from_method(method: &str) -> Option { + [Self::MessageSubmit, Self::GitStatus] + .into_iter() + .find(|candidate| candidate.as_str() == method) + } } /// Params for `kqode.message.submit`. +/// +/// `turnId` is generated by the client so it can register notification handlers +/// before sending, correlating streamed events without depending on ack +/// ordering. Kept in lockstep with the TypeScript `MessageSubmitParams`. #[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct MessageSubmitParams { pub text: String, + pub turn_id: String, } -/// Result for `kqode.message.submit`. +/// Result for `kqode.message.submit`: an immediate streaming ack. +/// +/// `status` is one of [`SUBMIT_STATUS_STREAMING`] or +/// [`SUBMIT_STATUS_NEEDS_CONFIGURATION`]; `turnId` echoes the client's id. #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct MessageSubmitResult { - pub message: &'static str, - pub received_text: String, + pub turn_id: String, + pub status: &'static str, } -impl From for MessageSubmitResult { - fn from(params: MessageSubmitParams) -> Self { - Self { - message: ACK_MESSAGE, - received_text: params.text, - } - } +/// Payload for [`TOKEN_DELTA_METHOD`]. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenDeltaParams { + pub turn_id: String, + pub delta: String, +} + +/// Payload for [`TURN_END_METHOD`]. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnEndParams { + pub turn_id: String, + pub finish_reason: Option, +} + +/// Payload for [`TURN_ERROR_METHOD`]. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnErrorParams { + pub turn_id: String, + pub error_kind: String, + pub message: String, +} + +/// Result for `kqode.git.status`: the formatted working-tree label, or `null` +/// when the workspace is not a git repository (or `git` could not be queried). +/// The backend owns parsing and formatting; the client renders `label` verbatim. +/// Kept in lockstep with the TypeScript `GitStatusResult`. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStatusResult { + pub label: Option, } diff --git a/tests/git_status.rs b/tests/git_status.rs new file mode 100644 index 00000000..0489f948 --- /dev/null +++ b/tests/git_status.rs @@ -0,0 +1,38 @@ +#[path = "common/rpc.rs"] +mod rpc; + +use kqode::protocol::RpcMethod; +use serde_json::json; + +use rpc::{backend_output, request_frame, response_frames}; + +#[test] +fn git_status_returns_a_formatted_label_for_the_workspace() { + // The integration harness spawns the backend with the crate root as its cwd, + // which is the KQode git repository, so the status query resolves a branch. + // Branch name and dirty flags are non-deterministic, so assert only the + // stable shape: a non-null label beginning with the branch glyph. + let output = backend_output(&request_frame( + 1, + RpcMethod::GitStatus.as_str(), + json!(null), + )); + + assert!(output.status.success(), "{output:?}"); + + let frames = response_frames(&output.stdout); + assert_eq!( + frames.len(), + 1, + "expected one git status response after the ready notification: {frames:?}" + ); + assert_eq!(frames[0]["id"], 1); + + let label = frames[0]["result"]["label"] + .as_str() + .expect("git status label is a string inside the KQode repository"); + assert!( + label.starts_with("⎇ "), + "label should start with the branch glyph: {label}" + ); +} diff --git a/tests/message_submit.rs b/tests/message_submit.rs index 018a6d72..c42fae22 100644 --- a/tests/message_submit.rs +++ b/tests/message_submit.rs @@ -1,7 +1,7 @@ #[path = "common/rpc.rs"] mod rpc; -use kqode::protocol::{ACK_MESSAGE, BACKEND_READY_METHOD, RpcMethod}; +use kqode::protocol::{BACKEND_READY_METHOD, RpcMethod, SUBMIT_STATUS_NEEDS_CONFIGURATION}; use serde_json::json; use rpc::{backend_output, parse_stdout_frames, request_frame, response_frames}; @@ -29,31 +29,47 @@ fn backend_announces_ready_before_handling_requests() { } #[test] -fn message_submit_returns_ack_with_received_text() { +fn message_submit_without_key_returns_needs_configuration() { + // With no API key (forced by the harness), submit must return an immediate + // ack routing the user to configuration and must not stream any tokens. let output = backend_output(&request_frame( 1, RpcMethod::MessageSubmit.as_str(), - json!({ "text": "hello from tui" }), + json!({ "text": "hello from tui", "turnId": "turn-1" }), )); assert!(output.status.success(), "{output:?}"); + + let all_frames = parse_stdout_frames(&output.stdout); + assert_eq!( + all_frames.len(), + 2, + "expected only the ready notification and one ack response: {all_frames:?}" + ); + let frames = response_frames(&output.stdout); assert_eq!(frames[0]["id"], 1); - assert_eq!(frames[0]["result"]["message"], ACK_MESSAGE); - assert_eq!(frames[0]["result"]["receivedText"], "hello from tui"); + assert_eq!( + frames[0]["result"]["status"], + SUBMIT_STATUS_NEEDS_CONFIGURATION + ); + assert_eq!(frames[0]["result"]["turnId"], "turn-1"); } #[test] -fn message_submit_preserves_unicode_newlines_and_empty_text() { - let text = " hello\nfrom tui 🌱 "; +fn message_submit_echoes_the_client_turn_id_for_each_submit() { let output = backend_output( &[ request_frame( 1, RpcMethod::MessageSubmit.as_str(), - json!({ "text": text }), + json!({ "text": "first", "turnId": "turn-a" }), + ), + request_frame( + 2, + RpcMethod::MessageSubmit.as_str(), + json!({ "text": "", "turnId": "turn-b" }), ), - request_frame(2, RpcMethod::MessageSubmit.as_str(), json!({ "text": "" })), ] .concat(), ); @@ -61,7 +77,15 @@ fn message_submit_preserves_unicode_newlines_and_empty_text() { assert!(output.status.success(), "{output:?}"); let frames = response_frames(&output.stdout); assert_eq!(frames[0]["id"], 1); + assert_eq!(frames[0]["result"]["turnId"], "turn-a"); + assert_eq!( + frames[0]["result"]["status"], + SUBMIT_STATUS_NEEDS_CONFIGURATION + ); assert_eq!(frames[1]["id"], 2); - assert_eq!(frames[0]["result"]["receivedText"], text); - assert_eq!(frames[1]["result"]["receivedText"], ""); + assert_eq!(frames[1]["result"]["turnId"], "turn-b"); + assert_eq!( + frames[1]["result"]["status"], + SUBMIT_STATUS_NEEDS_CONFIGURATION + ); } diff --git a/tui/src/__tests__/App.submit.test.tsx b/tui/src/__tests__/App.submit.test.tsx index b0bf26d2..06ae6f17 100644 --- a/tui/src/__tests__/App.submit.test.tsx +++ b/tui/src/__tests__/App.submit.test.tsx @@ -1,34 +1,52 @@ +import os from 'node:os'; +import path from 'node:path'; import { createStore } from 'jotai'; import { describe, expect, it, vi } from 'vitest'; import { App } from '@/App.tsx'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; -import type { BackendClient } from '@contracts/backend/index.ts'; -import { ACK_MESSAGE } from '@contracts/backend/index.ts'; -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/index.ts'; +import type { + BackendClient, + StreamCallbacks, + StreamOutcome, + StreamSubmitParams +} from '@contracts/backend/index.ts'; import { columnsTestOverrideAtom, rowsTestOverrideAtom } from '@state/ui/index.ts'; import { backendClientAtom, productVersionAtom, workspaceCwdAtom } from '@state/global/index.ts'; import { promptQueueAtom } from '@state/promptQueue/index.ts'; +import { NEEDS_CONFIGURATION_MESSAGE } from '@libs/promptQueue/promptQueue.ts'; import { flushInput } from '@test/flushInput.ts'; import { renderWithJotai } from '@test/renderWithJotai.tsx'; -const workspaceCwd = 'C:\\Users\\kefeiqian\\Projects\\dummy-react-app'; +const workspaceCwd = path.join(os.homedir(), 'Projects', 'dummy-react-app'); -function renderApp(backendClient: BackendClient, columns = 80, rows = 40) { +function renderApp(backendClient: Partial, columns = 80, rows = 40) { const store = createStore(); store.set(productVersionAtom, '0.1.0'); store.set(workspaceCwdAtom, workspaceCwd); store.set(columnsTestOverrideAtom, columns); store.set(rowsTestOverrideAtom, rows); - store.set(backendClientAtom, backendClient); + // Fill the full seam so the after-turn git refresh has a gitStatus to call; + // streaming tests only supply submitStreaming. + const client: BackendClient = { + gitStatus: async () => null, + submitStreaming: async () => { + throw new Error('submitStreaming not provided'); + }, + ...backendClient + }; + store.set(backendClientAtom, client); return { store, ...renderWithJotai(, store) }; } -function echoBackend() { +// A fake backend that streams a canned reply (default: echoes the prompt with a +// prefix so the assistant text is distinguishable from the echoed prompt row). +function streamingBackend(reply: (text: string) => string = (text) => `reply: ${text}`) { return vi.fn( - async ({ text }: MessageSubmitParams): Promise => ({ - message: ACK_MESSAGE, - receivedText: text - }) + async ({ text }: StreamSubmitParams, { onDelta }: StreamCallbacks): Promise => { + const output = reply(text); + onDelta(output); + return { kind: 'completed', text: output, finishReason: 'stop' }; + } ); } @@ -53,39 +71,45 @@ async function submit(stdin: { write: (data: string) => void }, text: string): P await flushInput(); } -describe('App submit and ACK output', () => { - it('appends the prompt and the Rust backend ACK when Enter is pressed', async () => { - const submitMessage = echoBackend(); - const { lastFrame, stdin } = renderApp({ submitMessage }); +describe('App submit and streaming output', () => { + it('appends the prompt and streams the assistant reply when Enter is pressed', async () => { + const submitStreaming = streamingBackend(); + const { lastFrame, stdin } = renderApp({ submitStreaming }); await submit(stdin, 'hello from tui'); const frame = await waitForFrame(lastFrame, (output) => - output.includes('Rust backend ACK - received: hello from tui') + output.includes('reply: hello from tui') ); expect(frame).toContain('❯ hello from tui'); - expect(submitMessage).toHaveBeenCalledWith({ text: 'hello from tui' }); + expect(submitStreaming).toHaveBeenCalledWith( + { text: 'hello from tui' }, + expect.objectContaining({ onDelta: expect.any(Function) }) + ); }); it('preserves Unicode and surrounding spaces in the backend result', async () => { - const submitMessage = echoBackend(); - const { lastFrame, stdin } = renderApp({ submitMessage }, 120); + const submitStreaming = streamingBackend(); + const { lastFrame, stdin } = renderApp({ submitStreaming }, 120); await submit(stdin, ' café ☕ '); await waitForFrame(lastFrame, (output) => output.includes('café ☕')); - expect(submitMessage).toHaveBeenCalledWith({ text: ' café ☕ ' }); + expect(submitStreaming).toHaveBeenCalledWith( + { text: ' café ☕ ' }, + expect.objectContaining({ onDelta: expect.any(Function) }) + ); }); it('queues consecutive submits, marking only the later prompts pending', async () => { - const pending: Array<{ text: string; resolve: (result: MessageSubmitResult) => void }> = []; - const submitMessage = vi.fn( - (params: MessageSubmitParams): Promise => + const pending: Array<{ text: string; resolve: (outcome: StreamOutcome) => void }> = []; + const submitStreaming = vi.fn( + (params: StreamSubmitParams): Promise => new Promise((resolve) => { pending.push({ text: params.text, resolve }); }) ); - const { lastFrame, stdin } = renderApp({ submitMessage }); + const { lastFrame, stdin } = renderApp({ submitStreaming }); await submit(stdin, 'first'); await submit(stdin, 'second'); @@ -95,28 +119,35 @@ describe('App submit and ACK output', () => { lastFrame, (output) => output.includes('second (pending)') && output.includes('third (pending)') ); - expect(submitMessage).toHaveBeenCalledTimes(1); - expect(submitMessage).toHaveBeenCalledWith({ text: 'first' }); + expect(submitStreaming).toHaveBeenCalledTimes(1); + expect(submitStreaming).toHaveBeenNthCalledWith( + 1, + { text: 'first' }, + expect.objectContaining({ onDelta: expect.any(Function) }) + ); expect(queuedFrame).toContain('❯ first'); expect(queuedFrame).not.toContain('first (pending)'); - pending[0]?.resolve({ message: ACK_MESSAGE, receivedText: 'first' }); + pending[0]?.resolve({ kind: 'completed', text: 'first reply', finishReason: 'stop' }); const drainedFrame = await waitForFrame( lastFrame, - (output) => - output.includes('Rust backend ACK - received: first') && !output.includes('second (pending)') + (output) => output.includes('first reply') && !output.includes('second (pending)') + ); + expect(submitStreaming).toHaveBeenCalledTimes(2); + expect(submitStreaming).toHaveBeenNthCalledWith( + 2, + { text: 'second' }, + expect.objectContaining({ onDelta: expect.any(Function) }) ); - expect(submitMessage).toHaveBeenCalledTimes(2); - expect(submitMessage).toHaveBeenNthCalledWith(2, { text: 'second' }); expect(drainedFrame).toContain('third (pending)'); }); it('shows a red backend failure for the matching prompt', async () => { - const submitMessage = vi.fn(async (): Promise => { + const submitStreaming = vi.fn(async (): Promise => { throw new BackendClientError(BackendErrorKind.Transport, 'connection died'); }); - const { lastFrame, stdin } = renderApp({ submitMessage }); + const { lastFrame, stdin } = renderApp({ submitStreaming }); await submit(stdin, 'will fail'); @@ -128,13 +159,8 @@ describe('App submit and ACK output', () => { }); it('escapes terminal-control characters in backend output before rendering', async () => { - const submitMessage = vi.fn( - async (): Promise => ({ - message: ACK_MESSAGE, - receivedText: 'evil\u001b[2Jcleared' - }) - ); - const { lastFrame, stdin } = renderApp({ submitMessage }, 120, 20); + const submitStreaming = streamingBackend(() => 'evil\u001b[2Jcleared'); + const { lastFrame, stdin } = renderApp({ submitStreaming }, 120, 20); await submit(stdin, 'trigger'); @@ -143,18 +169,18 @@ describe('App submit and ACK output', () => { }); it('does not call the backend for whitespace-only submits', async () => { - const submitMessage = echoBackend(); - const { stdin } = renderApp({ submitMessage }); + const submitStreaming = streamingBackend(); + const { stdin } = renderApp({ submitStreaming }); await submit(stdin, ' '); await flushInput(); - expect(submitMessage).not.toHaveBeenCalled(); + expect(submitStreaming).not.toHaveBeenCalled(); }); it('posts an unknown slash command and its error into the body without a backend call', async () => { - const submitMessage = echoBackend(); - const { lastFrame, stdin, store } = renderApp({ submitMessage }); + const submitStreaming = streamingBackend(); + const { lastFrame, stdin, store } = renderApp({ submitStreaming }); await submit(stdin, '/nope'); @@ -162,7 +188,39 @@ describe('App submit and ACK output', () => { output.includes('Unknown command: /nope') ); expect(frame).toContain('❯ /nope'); - expect(submitMessage).not.toHaveBeenCalled(); + expect(submitStreaming).not.toHaveBeenCalled(); expect(store.get(promptQueueAtom).some((item) => item.state === 'active')).toBe(false); }); + + it('renders a themed provider error when the streamed turn fails', async () => { + const submitStreaming = vi.fn( + async (): Promise => ({ + kind: 'error', + errorKind: 'auth', + message: 'Kimi rejected the API key' + }) + ); + const { lastFrame, stdin } = renderApp({ submitStreaming }); + + await submit(stdin, 'needs a good key'); + + const frame = await waitForFrame(lastFrame, (output) => + output.includes('Kimi rejected the API key') + ); + expect(frame).toContain('❯ needs a good key'); + expect(frame).toContain('ERROR:'); + }); + + it('routes to configuration guidance when no key is set', async () => { + const submitStreaming = vi.fn( + async (): Promise => ({ kind: 'needsConfiguration' }) + ); + const { lastFrame, stdin } = renderApp({ submitStreaming }); + + await submit(stdin, 'hello'); + + await waitForFrame(lastFrame, (output) => + output.includes(NEEDS_CONFIGURATION_MESSAGE.split('.')[0]) + ); + }); }); diff --git a/tui/src/__tests__/components/HomeScreen.test.tsx b/tui/src/__tests__/components/HomeScreen.test.tsx index b8ef6f6a..8cce5607 100644 --- a/tui/src/__tests__/components/HomeScreen.test.tsx +++ b/tui/src/__tests__/components/HomeScreen.test.tsx @@ -11,7 +11,7 @@ import { PROMPT_MAX_BYTES } from '@libs/composer/promptText.ts'; import { bodyEntriesAtom, columnsTestOverrideAtom, - gitStatusLabelTestOverrideAtom, + gitStatusLabelAtom, rowsTestOverrideAtom } from '@state/ui/index.ts'; import { productVersionAtom, workspaceCwdAtom } from '@state/global/index.ts'; @@ -19,6 +19,9 @@ import { flushInput } from '@test/flushInput.ts'; import { renderWithJotai } from '@test/renderWithJotai.tsx'; import { theme } from '@theme/themeConfig.ts'; +// Build the workspace under the real home dir (not a hard-coded C:\ string) so +// formatDisplayCwd collapses it to a `~`-relative path on every OS, keeping the +// cwd-row assertions valid on the Linux CI runner as well as Windows. const workspaceCwd = path.join(os.homedir(), 'Projects', 'KQode'); const displayCwd = `~${path.sep}${path.join('Projects', 'KQode')}`; const projectsKQode = path.join('Projects', 'KQode'); @@ -44,7 +47,7 @@ function renderHomeScreen({ store.set(productVersionAtom, productVersion); store.set(workspaceCwdAtom, screenWorkspaceCwd); if (gitStatusLabel !== undefined) { - store.set(gitStatusLabelTestOverrideAtom, gitStatusLabel); + store.set(gitStatusLabelAtom, gitStatusLabel); } if (columns !== undefined) { store.set(columnsTestOverrideAtom, columns); diff --git a/tui/src/__tests__/components/PromptComposer.test.tsx b/tui/src/__tests__/components/PromptComposer.test.tsx index 7570ed1a..06854c5a 100644 --- a/tui/src/__tests__/components/PromptComposer.test.tsx +++ b/tui/src/__tests__/components/PromptComposer.test.tsx @@ -10,8 +10,9 @@ import { commandMenuDismissedAtom, highlightedCommandAtom } from '@state/ui/comm import { armedActionAtom } from '@state/ui/index.ts'; import { ArmedAction } from '@constants/ui.ts'; import { helpVisibleAtom } from '@state/ui/help/index.ts'; -import { composerStateAtom } from '@state/ui/composer/index.ts'; -import { submittedPromptEntriesAtom } from '@state/ui/index.ts'; +import { composerScrollOffsetRowsAtom, composerStateAtom } from '@state/ui/composer/index.ts'; +import { columnsTestOverrideAtom, rowsTestOverrideAtom } from '@state/ui/dimensions.ts'; +import { scrollComposerByRowsAtom, submittedPromptEntriesAtom } from '@state/ui/index.ts'; import { flushInput } from '@test/flushInput.ts'; import { renderWithJotai } from '@test/renderWithJotai.tsx'; @@ -67,6 +68,33 @@ describe('PromptComposer', () => { expect(onSubmit).toHaveBeenCalledWith('first\nsecond'); }); + it('scrolls the caret back into view after a backslash-Enter edit that keeps the cursor index', async () => { + const store = createStore(); + store.set(columnsTestOverrideAtom, 60); + store.set(rowsTestOverrideAtom, 24); + // A long prompt (overflows the composer cap) ending in a backslash, caret at end. + const text = `${Array.from({ length: 20 }, (_, index) => `line ${index}`).join('\n')}\\`; + store.set(composerStateAtom, { text, cursorIndex: text.length, validationError: null }); + + const { stdin } = renderWithJotai(, store); + await flushInput(); + + // Peek toward the top so the caret (at the end) is scrolled off-window. + store.set(scrollComposerByRowsAtom, 999); + await flushInput(); + expect(store.get(composerScrollOffsetRowsAtom)).toBeGreaterThan(0); + + // Bare Enter on the trailing `\` deletes it and inserts a newline in one + // keypress: the text changes but the net cursor index does not. The caret + // must still snap back into view (regression guard for the effect keying on + // text as well as cursor index). + stdin.write('\r'); + await flushInput(); + + expect(store.get(composerStateAtom).text).toBe(`${text.slice(0, -1)}\n`); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(0); + }); + it('uses backslash followed by Enter as a newline fallback without submitting', async () => { const onSubmit = vi.fn(); const { lastFrame, stdin } = renderWithJotai( @@ -379,4 +407,59 @@ describe('PromptComposer', () => { expect(store.get(composerStateAtom).text).toBe('hellox'); expect(lastFrame() ?? '').toContain('hellox'); }); + + it('renders the cursor-follow bottom window of an overflowing prompt at offset 0', () => { + const store = createStore(); + store.set(composerStateAtom, { + text: '1111\n2222\n3333\n4444\n5555', + cursorIndex: 24, + validationError: null + }); + + const { lastFrame } = renderWithJotai( + , + store + ); + const frame = lastFrame() ?? ''; + + expect(frame).toContain('5555'); + expect(frame).not.toContain('1111'); + }); + + it('scrolls the composer view to earlier rows when a scroll offset is set', () => { + const store = createStore(); + store.set(composerStateAtom, { + text: '1111\n2222\n3333\n4444\n5555', + cursorIndex: 24, + validationError: null + }); + store.set(composerScrollOffsetRowsAtom, 2); + + const { lastFrame } = renderWithJotai( + , + store + ); + const frame = lastFrame() ?? ''; + + expect(frame).toContain('1111'); + expect(frame).not.toContain('5555'); + }); + + it('moves the cursor between visual lines with the Up and Down arrows', async () => { + const store = createStore(); + const { stdin } = renderWithJotai(, store); + store.set(composerStateAtom, { + text: 'aaa\nbbb\nccc', + cursorIndex: 11, + validationError: null + }); + + stdin.write('\u001B[A'); // Up + await flushInput(); + expect(store.get(composerStateAtom).cursorIndex).toBe(7); + + stdin.write('\u001B[B'); // Down + await flushInput(); + expect(store.get(composerStateAtom).cursorIndex).toBe(11); + }); }); diff --git a/tui/src/backend/client/__tests__/backendClient.test.ts b/tui/src/backend/client/__tests__/backendClient.test.ts index 04f6b221..096d9d71 100644 --- a/tui/src/backend/client/__tests__/backendClient.test.ts +++ b/tui/src/backend/client/__tests__/backendClient.test.ts @@ -1,4 +1,6 @@ import { PassThrough } from 'node:stream'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -10,8 +12,13 @@ import { } from 'vscode-jsonrpc/node'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; import { type LaunchedBackend } from '@backend/process/backendProcess.ts'; -import { ACK_MESSAGE } from '@contracts/backend/index.ts'; -import { messageSubmitRequest, backendReadyNotification } from '@backend/protocol/messageProtocol.ts'; +import { SUBMIT_STATUS_STREAMING } from '@contracts/backend/index.ts'; +import { + messageSubmitRequest, + backendReadyNotification, + tokenDeltaNotification, + turnEndNotification +} from '@backend/protocol/messageProtocol.ts'; import type { MessageSubmitResult } from '@contracts/backend/index.ts'; import { BackendLifecycleState, @@ -30,8 +37,18 @@ type FakeBackend = { let openServers: MessageConnection[] = []; +// A fake backend that streams the submitted text back as one delta then ends +// the turn, so `submitStreaming` resolves `completed` with that exact text. function ack(server: MessageConnection): void { - server.onRequest(messageSubmitRequest, ({ text }) => ({ message: ACK_MESSAGE, receivedText: text })); + server.onRequest(messageSubmitRequest, ({ text, turnId }) => { + queueMicrotask(async () => { + if (text.length > 0) { + await server.sendNotification(tokenDeltaNotification, { turnId, delta: text }); + } + await server.sendNotification(turnEndNotification, { turnId, finishReason: 'stop' }); + }); + return { turnId, status: SUBMIT_STATUS_STREAMING }; + }); } function makeFakeBackend( @@ -95,8 +112,8 @@ describe('createBackendClient (fake backend)', () => { expect(client.getState()).toBe(BackendLifecycleState.Ready); expect(launch).toHaveBeenCalledTimes(1); - const result = await client.submitMessage({ text: 'hello' }); - expect(result).toEqual({ message: ACK_MESSAGE, receivedText: 'hello' }); + const result = await client.submitStreaming({ text: 'hello' }, { onDelta: () => {} }); + expect(result).toEqual({ kind: 'completed', text: 'hello', finishReason: 'stop' }); expect(launch).toHaveBeenCalledTimes(1); client.dispose(); }); @@ -106,9 +123,9 @@ describe('createBackendClient (fake backend)', () => { const client = createBackendClient({ launch: async () => fake.launched }); expect(client.getState()).toBe(BackendLifecycleState.Idle); - const result = await client.submitMessage({ text: 'hello' }); + const result = await client.submitStreaming({ text: 'hello' }, { onDelta: () => {} }); - expect(result).toEqual({ message: ACK_MESSAGE, receivedText: 'hello' }); + expect(result).toEqual({ kind: 'completed', text: 'hello', finishReason: 'stop' }); expect(client.getState()).toBe(BackendLifecycleState.Ready); client.dispose(); }); @@ -137,13 +154,17 @@ describe('createBackendClient (fake backend)', () => { const launch = vi.fn(async () => fake.launched); const client = createBackendClient({ launch }); - await expect(client.submitMessage({ text: 'x' })).rejects.toMatchObject({ + await expect( + client.submitStreaming({ text: 'x' }, { onDelta: () => {} }) + ).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); expect(client.getState()).toBe(BackendLifecycleState.Ready); expect(fake.disposed()).toBe(false); - await expect(client.submitMessage({ text: 'y' })).rejects.toMatchObject({ + await expect( + client.submitStreaming({ text: 'y' }, { onDelta: () => {} }) + ).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); expect(launch).toHaveBeenCalledTimes(1); @@ -159,14 +180,16 @@ describe('createBackendClient (fake backend)', () => { const launch = vi.fn(async () => backends.shift()?.launched as LaunchedBackend); const client = createBackendClient({ launch, requestTimeoutMs: 100 }); - await expect(client.submitMessage({ text: 'first' })).rejects.toMatchObject({ + await expect( + client.submitStreaming({ text: 'first' }, { onDelta: () => {} }) + ).rejects.toMatchObject({ kind: BackendErrorKind.Timeout }); expect(client.getState()).toBe(BackendLifecycleState.Dead); expect(hung.disposed()).toBe(true); - const result = await client.submitMessage({ text: 'second' }); - expect(result.receivedText).toBe('second'); + const result = await client.submitStreaming({ text: 'second' }, { onDelta: () => {} }); + expect(result).toEqual({ kind: 'completed', text: 'second', finishReason: 'stop' }); expect(client.getState()).toBe(BackendLifecycleState.Ready); expect(launch).toHaveBeenCalledTimes(2); client.dispose(); @@ -176,7 +199,7 @@ describe('createBackendClient (fake backend)', () => { const fake = makeFakeBackend(ack); const client = createBackendClient({ launch: async () => fake.launched }); - await client.submitMessage({ text: 'alive' }); + await client.submitStreaming({ text: 'alive' }, { onDelta: () => {} }); expect(client.getState()).toBe(BackendLifecycleState.Ready); fake.emitExit(); @@ -194,7 +217,9 @@ describe('createBackendClient (fake backend)', () => { client.dispose(); - await expect(client.submitMessage({ text: 'after dispose' })).rejects.toMatchObject({ + await expect( + client.submitStreaming({ text: 'after dispose' }, { onDelta: () => {} }) + ).rejects.toMatchObject({ kind: BackendErrorKind.Launch }); // The disposed client is terminal: no fresh backend is launched. @@ -213,7 +238,7 @@ describe('createBackendClient (fake backend)', () => { ); const client = createBackendClient({ launch }); - const submit = client.submitMessage({ text: 'race' }); + const submit = client.submitStreaming({ text: 'race' }, { onDelta: () => {} }); client.dispose(); resolveLaunch?.(fake.launched); @@ -225,15 +250,24 @@ describe('createBackendClient (fake backend)', () => { describe('createSourceBackendClient (integration)', () => { it( - 'starts the Rust backend, submits, and receives the ACK with exact receivedText', + 'builds and launches the Rust backend, routing to configuration without a key', async () => { - const client = createSourceBackendClient({ repoRoot, workspaceCwd: repoRoot }); + // Provider setup lands later, so bootstrap submit deterministically returns + // needsConfiguration regardless of the workspace. + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'kqode-src-client-')); + const client = createSourceBackendClient({ repoRoot, workspaceCwd }); try { - const result = await client.submitMessage({ text: ' café\n☕ ' }); - expect(result.message).toBe(ACK_MESSAGE); - expect(result.receivedText).toBe(' café\n☕ '); + const outcome = await client.submitStreaming({ text: 'hello' }, { onDelta: () => {} }); + expect(outcome).toEqual({ kind: 'needsConfiguration' }); } finally { client.dispose(); + // Best-effort: on Windows the just-killed backend may still hold the cwd + // handle briefly; the OS reclaims the temp dir regardless. + try { + fs.rmSync(workspaceCwd, { recursive: true, force: true }); + } catch { + /* temp cleanup is best-effort */ + } } }, INTEGRATION_TIMEOUT_MS diff --git a/tui/src/backend/client/__tests__/packagedBackendClient.test.ts b/tui/src/backend/client/__tests__/packagedBackendClient.test.ts index 6417fe47..a2afecbe 100644 --- a/tui/src/backend/client/__tests__/packagedBackendClient.test.ts +++ b/tui/src/backend/client/__tests__/packagedBackendClient.test.ts @@ -32,7 +32,9 @@ describe('createPackagedBackendClient', () => { cacheBaseDir: tempCacheBase() }); - await expect(client.submitMessage({ text: 'hello' })).rejects.toMatchObject({ + await expect( + client.submitStreaming({ text: 'hello' }, { onDelta: () => {} }) + ).rejects.toMatchObject({ kind: BackendErrorKind.Launch }); expect(client.getState()).toBe(BackendLifecycleState.Dead); diff --git a/tui/src/backend/client/backendClient.ts b/tui/src/backend/client/backendClient.ts index f5c62935..2b3b8846 100644 --- a/tui/src/backend/client/backendClient.ts +++ b/tui/src/backend/client/backendClient.ts @@ -7,10 +7,13 @@ import { createMessageConnectionClient } from '@backend/client/messageConnection import { openReadyConnection } from '@backend/client/backendReadiness.ts'; import { isFatalBackendError, - toLaunchError, - withRequestTimeout + toLaunchError } from '@backend/client/backendClientErrors.ts'; -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/index.ts'; +import type { + StreamCallbacks, + StreamOutcome, + StreamSubmitParams +} from '@contracts/backend/index.ts'; /** Lifecycle of the TUI-owned backend connection. */ export const BackendLifecycleState = { @@ -60,7 +63,7 @@ type BackendSession = { * backend (persisted session restore is added with the session methods), never * silently and never auto-replaying interrupted work. * - * `dispose()` is terminal: once disposed, `ensureStarted`/`submitMessage` reject + * `dispose()` is terminal: once disposed, `ensureStarted`/`submitStreaming` reject * with a `launch`-kind {@link BackendClientError} without spawning a replacement, * so a torn-down client can never orphan a new backend process. */ @@ -142,7 +145,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien const opened: BackendSession = { backend, connection, - client: createMessageConnectionClient(connection) + client: createMessageConnectionClient(connection, { requestTimeoutMs }) }; session = opened; state = BackendLifecycleState.Ready; @@ -169,13 +172,30 @@ export function createBackendClient(options: BackendClientOptions): BackendClien async ensureStarted(): Promise { await ensureSession(); }, - async submitMessage(params: MessageSubmitParams): Promise { + async submitStreaming( + params: StreamSubmitParams, + callbacks: StreamCallbacks + ): Promise { if (disposed) { throw disposedError(); } const active = await ensureSession(); try { - return await withRequestTimeout(active.client.submitMessage(params), requestTimeoutMs); + return await active.client.submitStreaming(params, callbacks); + } catch (error) { + if (isFatalBackendError(error)) { + markDead(); + } + throw error; + } + }, + async gitStatus(): Promise { + if (disposed) { + throw disposedError(); + } + const active = await ensureSession(); + try { + return await active.client.gitStatus(); } catch (error) { if (isFatalBackendError(error)) { markDead(); diff --git a/tui/src/backend/client/messageConnectionClient.ts b/tui/src/backend/client/messageConnectionClient.ts index 3126ad72..cd80aa02 100644 --- a/tui/src/backend/client/messageConnectionClient.ts +++ b/tui/src/backend/client/messageConnectionClient.ts @@ -1,21 +1,121 @@ +import { randomUUID } from 'node:crypto'; import { type MessageConnection, ResponseError } from 'vscode-jsonrpc'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; -import type { BackendClient } from '@contracts/backend/index.ts'; -import { messageSubmitRequest } from '@backend/protocol/messageProtocol.ts'; -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/index.ts'; +import { SUBMIT_STATUS_NEEDS_CONFIGURATION } from '@contracts/backend/index.ts'; +import type { + BackendClient, + StreamCallbacks, + StreamOutcome, + StreamSubmitParams +} from '@contracts/backend/index.ts'; +import { + gitStatusRequest, + messageSubmitRequest, + tokenDeltaNotification, + turnEndNotification, + turnErrorNotification +} from '@backend/protocol/messageProtocol.ts'; +import { withRequestTimeout } from '@backend/client/backendClientErrors.ts'; +import { DEFAULT_REQUEST_TIMEOUT_MS } from '@constants/backend.ts'; + +/** Per-turn hooks the notification handlers dispatch to, keyed by `turnId`. */ +type ActiveTurn = { + onDelta: (delta: string) => void; + complete: (finishReason: string | null) => void; + fail: (errorKind: string, message: string) => void; + die: (reason: string) => void; +}; + +/** Composition inputs for {@link createMessageConnectionClient}. */ +export type MessageConnectionClientOptions = { + /** Ceiling for the streaming ack response (not the whole stream). */ + requestTimeoutMs?: number; +}; /** * Builds a {@link BackendClient} over an already-established JSON-RPC connection. * - * The caller owns the connection lifecycle; this wrapper only routes the - * `kqode.message.submit` request and normalizes failures into typed errors, so - * it can be exercised over in-memory streams without a Rust child process. + * The caller owns the connection lifecycle. Streamed turns are correlated by a + * client-generated `turnId`: the notification handlers are registered before the + * submit request is sent, so a `tokenDelta`/`turnEnd` that races ahead of the ack + * still matches. A turn resolves on `kqode/turnEnd` (completed) or + * `kqode/turnError`, and rejects only if the ack times out or the connection + * dies mid-stream — so it can be exercised over in-memory streams without a Rust + * child process. */ -export function createMessageConnectionClient(connection: MessageConnection): BackendClient { +export function createMessageConnectionClient( + connection: MessageConnection, + options: MessageConnectionClientOptions = {} +): BackendClient { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const activeTurns = new Map(); + + connection.onNotification(tokenDeltaNotification, ({ turnId, delta }) => { + activeTurns.get(turnId)?.onDelta(delta); + }); + connection.onNotification(turnEndNotification, ({ turnId, finishReason }) => { + activeTurns.get(turnId)?.complete(finishReason); + }); + connection.onNotification(turnErrorNotification, ({ turnId, errorKind, message }) => { + activeTurns.get(turnId)?.fail(errorKind, message); + }); + + const failAllTurns = (reason: string): void => { + for (const turn of [...activeTurns.values()]) { + turn.die(reason); + } + }; + connection.onClose(() => failAllTurns('backend connection closed before the turn completed')); + connection.onError(() => failAllTurns('backend connection errored before the turn completed')); + return { - async submitMessage(params: MessageSubmitParams): Promise { + submitStreaming(params: StreamSubmitParams, callbacks: StreamCallbacks): Promise { + const turnId = randomUUID(); + return new Promise((resolve, reject) => { + let settled = false; + let text = ''; + const finish = (settle: () => void): void => { + if (settled) { + return; + } + settled = true; + activeTurns.delete(turnId); + settle(); + }; + + activeTurns.set(turnId, { + onDelta: (delta) => { + text += delta; + callbacks.onDelta(delta); + }, + complete: (finishReason) => + finish(() => resolve({ kind: 'completed', text, finishReason })), + fail: (errorKind, message) => finish(() => resolve({ kind: 'error', errorKind, message })), + die: (reason) => + finish(() => reject(new BackendClientError(BackendErrorKind.Transport, reason))) + }); + + withRequestTimeout( + connection.sendRequest(messageSubmitRequest, { text: params.text, turnId }), + requestTimeoutMs + ).then( + (ack) => { + if (ack.status === SUBMIT_STATUS_NEEDS_CONFIGURATION) { + finish(() => resolve({ kind: 'needsConfiguration' })); + } + // Otherwise the turn is streaming: wait for turnEnd/turnError. + }, + (error: unknown) => finish(() => reject(toBackendClientError(error))) + ); + }); + }, + async gitStatus(): Promise { try { - return await connection.sendRequest(messageSubmitRequest, params); + const result = await withRequestTimeout( + connection.sendRequest(gitStatusRequest), + requestTimeoutMs + ); + return result.label; } catch (error) { throw toBackendClientError(error); } diff --git a/tui/src/backend/packaged/__tests__/materializeBackend.test.ts b/tui/src/backend/packaged/__tests__/materializeBackend.test.ts index c1857989..134f9da2 100644 --- a/tui/src/backend/packaged/__tests__/materializeBackend.test.ts +++ b/tui/src/backend/packaged/__tests__/materializeBackend.test.ts @@ -39,6 +39,15 @@ function asset(bytes: Buffer, sha = sha256(bytes)): EmbeddedBackendAsset & { cal }; } +// Emulate writeBinary's user-only permissions so inspectExisting classifies the +// staged file as reusable on Unix, where loose group/other perms are treated as +// stale. Without this the concurrent-race fallback tests fail on non-Windows CI. +function writeCacheBinary(binaryPath: string, runtimeDir: string, bytes: Buffer): void { + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync(binaryPath, bytes); + fs.chmodSync(binaryPath, 0o700); +} + afterEach(() => { vi.restoreAllMocks(); while (tempDirs.length > 0) { @@ -216,11 +225,7 @@ describe('materializePackagedBackend', () => { // Simulate the winner writing the valid binary, then our own write failing // (e.g. the winner already spawned and locked it on Windows). const losingWrite = (binaryPath: string, runtimeDir: string): void => { - fs.mkdirSync(runtimeDir, { recursive: true }); - fs.writeFileSync(binaryPath, bytes); - if (!isWindows) { - fs.chmodSync(binaryPath, 0o700); - } + writeCacheBinary(binaryPath, runtimeDir, bytes); throw new Error('EBUSY: binary is locked by the winning instance'); }; @@ -258,11 +263,7 @@ describe('materializePackagedBackend', () => { // The injected write leaves a genuinely valid binary (as a concurrent winner // would)... const write = (binaryPath: string, runtimeDir: string): void => { - fs.mkdirSync(runtimeDir, { recursive: true }); - fs.writeFileSync(binaryPath, bytes); - if (!isWindows) { - fs.chmodSync(binaryPath, 0o700); - } + writeCacheBinary(binaryPath, runtimeDir, bytes); }; // ...but the immediate post-write read-back observes the atomic-replace gap // once (ENOENT), which must fall back to reusing the valid cache. diff --git a/tui/src/backend/packaged/backendCacheDir.ts b/tui/src/backend/packaged/backendCacheDir.ts index 77b7fef8..db9420b3 100644 --- a/tui/src/backend/packaged/backendCacheDir.ts +++ b/tui/src/backend/packaged/backendCacheDir.ts @@ -2,7 +2,7 @@ import os from 'node:os'; import path from 'node:path'; /** Per-user KQode home directory holding local runtime/data state. */ -export const KQODE_HOME_DIRNAME = '.kqcode'; +export const KQODE_HOME_DIRNAME = '.kqode'; /** Subdirectory under the KQode home for materialized backend binaries. */ export const BACKENDS_DIRNAME = 'backends'; @@ -10,7 +10,7 @@ export const BACKENDS_DIRNAME = 'backends'; /** Base name of the materialized packaged backend binary. */ export const PACKAGED_BACKEND_BASENAME = 'kqode-backend'; -/** Default per-user cache base, e.g. `~/.kqcode`. */ +/** Default per-user cache base, e.g. `~/.kqode`. */ export function defaultCacheBaseDir(homeDir: string = os.homedir()): string { return path.join(homeDir, KQODE_HOME_DIRNAME); } @@ -21,7 +21,7 @@ export function packagedBackendBinaryName(platform: NodeJS.Platform = process.pl } export type PackagedBackendPaths = { - /** Content-addressed directory, e.g. `~/.kqcode/backends/0.1.0/`. */ + /** Content-addressed directory, e.g. `~/.kqode/backends/0.1.0/`. */ runtimeDir: string; /** Absolute path to the materialized backend binary inside `runtimeDir`. */ binaryPath: string; diff --git a/tui/src/backend/process/__tests__/backendProcess.test.ts b/tui/src/backend/process/__tests__/backendProcess.test.ts index 77364786..ff4171bc 100644 --- a/tui/src/backend/process/__tests__/backendProcess.test.ts +++ b/tui/src/backend/process/__tests__/backendProcess.test.ts @@ -1,4 +1,6 @@ import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; import type { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -8,7 +10,7 @@ import { spawnBackend, type LaunchedBackend } from '@backend/process/backendProcess.ts'; -import { ACK_MESSAGE, MESSAGE_SUBMIT_METHOD } from '@contracts/backend/index.ts'; +import { MESSAGE_SUBMIT_METHOD, SUBMIT_STATUS_NEEDS_CONFIGURATION } from '@contracts/backend/index.ts'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', '..'); const INTEGRATION_TIMEOUT_MS = 180_000; @@ -20,7 +22,7 @@ function frameRequest(payload: unknown): Buffer { function readResponseFrame( stream: Readable -): Promise<{ result: { message: string; receivedText: string } }> { +): Promise<{ result: { turnId: string; status: string } }> { return new Promise((resolve, reject) => { let buffer = Buffer.alloc(0); const cleanup = () => { @@ -62,12 +64,31 @@ function readResponseFrame( }); } -async function ackThroughLauncher(backend: LaunchedBackend, text: string): Promise { +async function submitThroughLauncher( + backend: LaunchedBackend, + text: string +): Promise<{ turnId: string; status: string }> { const response = readResponseFrame(backend.stdout); - backend.stdin.write(frameRequest({ jsonrpc: '2.0', id: 1, method: MESSAGE_SUBMIT_METHOD, params: { text } })); + backend.stdin.write( + frameRequest({ + jsonrpc: '2.0', + id: 1, + method: MESSAGE_SUBMIT_METHOD, + params: { text, turnId: 'turn-1' } + }) + ); const frame = await response; - expect(frame.result.message).toBe(ACK_MESSAGE); - return frame.result.receivedText; + return frame.result; +} + +// Best-effort temp cleanup: on Windows a just-killed backend may still hold its +// cwd handle for a moment; the OS reclaims the temp dir regardless. +function safeRemove(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } } describe('spawnBackend', () => { @@ -83,27 +104,36 @@ describe('spawnBackend', () => { describe('launchSourceBackend (integration)', () => { it( - 'builds and launches the backend when invoked from the repo root workspace', + 'builds and launches the backend and returns a well-formed ack', async () => { - const backend = await launchSourceBackend({ repoRoot, workspaceCwd: repoRoot }); + // Provider setup lands later, so bootstrap submit deterministically returns + // needsConfiguration regardless of the workspace. + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'kqode-launch-')); + const backend = await launchSourceBackend({ repoRoot, workspaceCwd }); try { - expect(await ackThroughLauncher(backend, 'hi from repo root')).toBe('hi from repo root'); + const result = await submitThroughLauncher(backend, 'hi from a temp workspace'); + expect(result.turnId).toBe('turn-1'); + expect(result.status).toBe(SUBMIT_STATUS_NEEDS_CONFIGURATION); } finally { backend.dispose(); + safeRemove(workspaceCwd); } }, INTEGRATION_TIMEOUT_MS ); it( - 'preserves a distinct workspace cwd such as the dummy React fixture path', + 'launches from a distinct workspace directory and still answers submit', async () => { - const workspaceCwd = path.join(repoRoot, 'tests', 'fixtures', 'dummy-react-app'); + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'kqode-launch-alt-')); const backend = await launchSourceBackend({ repoRoot, workspaceCwd }); try { - expect(await ackThroughLauncher(backend, 'café ☕')).toBe('café ☕'); + const result = await submitThroughLauncher(backend, 'café ☕'); + expect(result.turnId).toBe('turn-1'); + expect(result.status).toBe(SUBMIT_STATUS_NEEDS_CONFIGURATION); } finally { backend.dispose(); + safeRemove(workspaceCwd); } }, INTEGRATION_TIMEOUT_MS diff --git a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts index 98412e53..bf2a8187 100644 --- a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts +++ b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts @@ -8,8 +8,17 @@ import { import { ErrorCodes, type MessageConnection, ResponseError } from 'vscode-jsonrpc'; import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.ts'; import { createMessageConnectionClient } from '@backend/client/messageConnectionClient.ts'; -import { ACK_MESSAGE } from '@contracts/backend/index.ts'; -import { messageSubmitRequest } from '@backend/protocol/messageProtocol.ts'; +import { + SUBMIT_STATUS_NEEDS_CONFIGURATION, + SUBMIT_STATUS_STREAMING +} from '@contracts/backend/index.ts'; +import { + gitStatusRequest, + messageSubmitRequest, + tokenDeltaNotification, + turnEndNotification, + turnErrorNotification +} from '@backend/protocol/messageProtocol.ts'; type PairedConnections = { client: MessageConnection; @@ -45,11 +54,16 @@ function pairedConnections(): PairedConnections { return pair; } -function ackingServer(server: MessageConnection): void { - server.onRequest(messageSubmitRequest, ({ text }) => ({ - message: ACK_MESSAGE, - receivedText: text - })); +function streamingServer(server: MessageConnection, deltas: readonly string[]): void { + server.onRequest(messageSubmitRequest, ({ turnId }) => { + queueMicrotask(async () => { + for (const delta of deltas) { + await server.sendNotification(tokenDeltaNotification, { turnId, delta }); + } + await server.sendNotification(turnEndNotification, { turnId, finishReason: 'stop' }); + }); + return { turnId, status: SUBMIT_STATUS_STREAMING }; + }); } afterEach(() => { @@ -60,26 +74,74 @@ afterEach(() => { }); describe('message protocol client', () => { - it('sends kqode.message.submit and receives an ACK success response', async () => { + it('streams token deltas and resolves completed with the concatenated text', async () => { + const { client, server } = pairedConnections(); + streamingServer(server, ['Hello', ', ', 'world']); + + const deltas: string[] = []; + const outcome = await createMessageConnectionClient(client).submitStreaming( + { text: 'hello from tui' }, + { onDelta: (delta) => deltas.push(delta) } + ); + + expect(deltas).toEqual(['Hello', ', ', 'world']); + expect(outcome).toEqual({ kind: 'completed', text: 'Hello, world', finishReason: 'stop' }); + }); + + it('preserves Unicode and whitespace across streamed deltas', async () => { const { client, server } = pairedConnections(); - ackingServer(server); + streamingServer(server, [' café\n', '☕ 日本語 ']); - const result = await createMessageConnectionClient(client).submitMessage({ - text: 'hello from tui' + const outcome = await createMessageConnectionClient(client).submitStreaming( + { text: 'unicode' }, + { onDelta: () => {} } + ); + + expect(outcome).toEqual({ + kind: 'completed', + text: ' café\n☕ 日本語 ', + finishReason: 'stop' + }); + }); + + it('resolves an error outcome when the backend emits kqode/turnError', async () => { + const { client, server } = pairedConnections(); + server.onRequest(messageSubmitRequest, ({ turnId }) => { + queueMicrotask(() => { + void server.sendNotification(turnErrorNotification, { + turnId, + errorKind: 'auth', + message: 'Kimi rejected the API key' + }); + }); + return { turnId, status: SUBMIT_STATUS_STREAMING }; }); - expect(result.message).toBe(ACK_MESSAGE); - expect(result.receivedText).toBe('hello from tui'); + const outcome = await createMessageConnectionClient(client).submitStreaming( + { text: 'boom' }, + { onDelta: () => {} } + ); + + expect(outcome).toEqual({ + kind: 'error', + errorKind: 'auth', + message: 'Kimi rejected the API key' + }); }); - it('preserves Unicode, surrounding spaces, and newlines in receivedText', async () => { + it('resolves a needs-configuration outcome when the ack reports no key', async () => { const { client, server } = pairedConnections(); - ackingServer(server); + server.onRequest(messageSubmitRequest, ({ turnId }) => ({ + turnId, + status: SUBMIT_STATUS_NEEDS_CONFIGURATION + })); - const text = ' café\n☕ 日本語 '; - const result = await createMessageConnectionClient(client).submitMessage({ text }); + const outcome = await createMessageConnectionClient(client).submitStreaming( + { text: 'no key' }, + { onDelta: () => {} } + ); - expect(result.receivedText).toBe(text); + expect(outcome).toEqual({ kind: 'needsConfiguration' }); }); it('surfaces JSON-RPC method errors as typed protocol client errors', async () => { @@ -88,9 +150,44 @@ describe('message protocol client', () => { throw new ResponseError(ErrorCodes.InvalidParams, 'invalid message submit params'); }); - const submit = createMessageConnectionClient(client).submitMessage({ text: 'boom' }); + const submit = createMessageConnectionClient(client).submitStreaming( + { text: 'boom' }, + { onDelta: () => {} } + ); await expect(submit).rejects.toBeInstanceOf(BackendClientError); await expect(submit).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); }); }); + +describe('git status request', () => { + it('resolves the formatted label the backend returns', async () => { + const { client, server } = pairedConnections(); + server.onRequest(gitStatusRequest, () => ({ label: '⎇ main*' })); + + const label = await createMessageConnectionClient(client).gitStatus(); + + expect(label).toBe('⎇ main*'); + }); + + it('resolves null when the workspace is not a git repository', async () => { + const { client, server } = pairedConnections(); + server.onRequest(gitStatusRequest, () => ({ label: null })); + + const label = await createMessageConnectionClient(client).gitStatus(); + + expect(label).toBeNull(); + }); + + it('surfaces a JSON-RPC error as a typed protocol client error', async () => { + const { client, server } = pairedConnections(); + server.onRequest(gitStatusRequest, () => { + throw new ResponseError(ErrorCodes.InternalError, 'git failed'); + }); + + const status = createMessageConnectionClient(client).gitStatus(); + + await expect(status).rejects.toBeInstanceOf(BackendClientError); + await expect(status).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); + }); +}); diff --git a/tui/src/backend/protocol/messageProtocol.ts b/tui/src/backend/protocol/messageProtocol.ts index 2c1dd78d..4917e916 100644 --- a/tui/src/backend/protocol/messageProtocol.ts +++ b/tui/src/backend/protocol/messageProtocol.ts @@ -1,6 +1,20 @@ -import { NotificationType0, RequestType } from 'vscode-jsonrpc'; -import { BACKEND_READY_METHOD, MESSAGE_SUBMIT_METHOD } from '@contracts/backend/index.ts'; -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/index.ts'; +import { NotificationType, NotificationType0, RequestType, RequestType0 } from 'vscode-jsonrpc'; +import { + BACKEND_READY_METHOD, + GIT_STATUS_METHOD, + MESSAGE_SUBMIT_METHOD, + TOKEN_DELTA_METHOD, + TURN_END_METHOD, + TURN_ERROR_METHOD +} from '@contracts/backend/index.ts'; +import type { + GitStatusResult, + MessageSubmitParams, + MessageSubmitResult, + TokenDeltaParams, + TurnEndParams, + TurnErrorParams +} from '@contracts/backend/index.ts'; /** * Typed request descriptor for `kqode.message.submit`. @@ -13,6 +27,15 @@ export const messageSubmitRequest = new RequestType(GIT_STATUS_METHOD); + /** * Typed descriptor for the backend's one-shot readiness notification. * @@ -22,3 +45,12 @@ export const messageSubmitRequest = new RequestType(TOKEN_DELTA_METHOD); + +/** Terminal "turn finished" notification carrying the finish reason. */ +export const turnEndNotification = new NotificationType(TURN_END_METHOD); + +/** Terminal "turn failed" notification carrying a sanitized provider error. */ +export const turnErrorNotification = new NotificationType(TURN_ERROR_METHOD); diff --git a/tui/src/backend/runtime/__tests__/backendRuntime.test.ts b/tui/src/backend/runtime/__tests__/backendRuntime.test.ts index 5b09c2e5..c9f56b2f 100644 --- a/tui/src/backend/runtime/__tests__/backendRuntime.test.ts +++ b/tui/src/backend/runtime/__tests__/backendRuntime.test.ts @@ -10,7 +10,8 @@ import type { RuntimeBackendClient } from '@backend/runtime/backendRuntime.ts'; function fakeClient(overrides: Partial = {}): RuntimeBackendClient { return { - submitMessage: vi.fn(), + submitStreaming: vi.fn(), + gitStatus: vi.fn().mockResolvedValue(null), ensureStarted: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), ...overrides @@ -69,7 +70,7 @@ describe('startBackendRuntime', () => { const failure = new BackendClientError(BackendErrorKind.Launch, 'backend unavailable'); const client = fakeClient({ ensureStarted: vi.fn().mockRejectedValue(failure), - submitMessage: vi.fn().mockRejectedValue(failure) + submitStreaming: vi.fn().mockRejectedValue(failure) }); startBackendRuntime(store, client); @@ -80,6 +81,9 @@ describe('startBackendRuntime', () => { const entries = store.get(submittedPromptEntriesAtom); expect(entries.some((entry) => entry.kind === 'error')).toBe(true); - expect(client.submitMessage).toHaveBeenCalledWith({ text: 'still here?' }); + expect(client.submitStreaming).toHaveBeenCalledWith( + { text: 'still here?' }, + expect.objectContaining({ onDelta: expect.any(Function) }) + ); }); }); diff --git a/tui/src/backend/runtime/backendRuntime.ts b/tui/src/backend/runtime/backendRuntime.ts index 5c3d8b99..91a344d3 100644 --- a/tui/src/backend/runtime/backendRuntime.ts +++ b/tui/src/backend/runtime/backendRuntime.ts @@ -1,6 +1,7 @@ import type { createStore } from 'jotai'; import type { BackendClient } from '@contracts/backend/index.ts'; import { backendClientAtom } from '@state/global/backend.ts'; +import { refreshGitStatusAtom } from '@state/ui/gitStatus.ts'; import { BACKEND_LOADING_HINT, startupStatusHintAtom } from '@state/ui/statusHint.ts'; type Store = ReturnType; @@ -28,6 +29,10 @@ export function startBackendRuntime(store: Store, client: RuntimeBackendClient): void client .ensureStarted() + .then(() => { + // Backend is ready: fetch the initial git label off the render path. + void store.set(refreshGitStatusAtom); + }) .catch(() => { // Keep the Dead-state client in the seam (do not dispose or clear it): the // next submit retries through ensureSession() and, on repeat failure, diff --git a/tui/src/components/HomeScreen/HomeScreenView.tsx b/tui/src/components/HomeScreen/HomeScreenView.tsx index 0780549f..b8c2ab0c 100644 --- a/tui/src/components/HomeScreen/HomeScreenView.tsx +++ b/tui/src/components/HomeScreen/HomeScreenView.tsx @@ -1,5 +1,5 @@ import { Box, useInput, useStdout } from 'ink'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { useEffect } from 'react'; import { BodyPane } from '@components/BodyPane.tsx'; import { CwdLine } from '@components/CwdLine.tsx'; @@ -10,25 +10,46 @@ import { StatusBar } from '@components/StatusBar.tsx'; import { DISABLE_SGR_MOUSE_TRACKING, ENABLE_SGR_MOUSE_TRACKING, - parseMouseWheelInput + parseMouseClickEvent, + parseMouseWheelEvent } from '@libs/terminal/mouse.ts'; +import { resolveWheelTarget } from '@components/HomeScreen/wheelRouting.ts'; +import { useCaretScrollSuppression } from '@components/HomeScreen/useCaretScrollSuppression.ts'; +import { resolveClickResult } from '@libs/composer/composerWindow.ts'; import { BODY_CWD_GAP_ROWS } from '@libs/tui/layout.ts'; import { bottomSpacerRowsAtom, + composerCanScrollAtom, + composerTopAtom, layoutAtom, - scrollBodyByRowsAtom + scrollBodyByRowsAtom, + scrollComposerByRowsAtom } from '@state/ui/index.ts'; import { columnsAtom, rowsAtom } from '@state/ui/index.ts'; import { commandMenuOpenAtom } from '@state/ui/commands/index.ts'; +import { + composerScrollOffsetRowsAtom, + composerStateAtom, + setComposerCursorWithOffsetAtom +} from '@state/ui/composer/index.ts'; import { theme } from '@theme/themeConfig.ts'; -import { MOUSE_WHEEL_SCROLL_ROWS } from '@constants/ui.ts'; +import { + COMPOSER_BACKGROUND_TOP_PADDING_ROWS, + MOUSE_WHEEL_SCROLL_ROWS, + PROMPT_PREFIX +} from '@constants/ui.ts'; export function HomeScreenView() { const { stdout } = useStdout(); const columns = useAtomValue(columnsAtom); const rows = useAtomValue(rowsAtom); const layout = useAtomValue(layoutAtom); + const composerTop = useAtomValue(composerTopAtom); + const composerCanScroll = useAtomValue(composerCanScrollAtom); const scrollBodyByRows = useSetAtom(scrollBodyByRowsAtom); + const scrollComposerByRows = useSetAtom(scrollComposerByRowsAtom); + const notifyScroll = useCaretScrollSuppression(); + const store = useStore(); useEffect(() => { if (!stdout.isTTY) { @@ -42,25 +63,63 @@ export function HomeScreenView() { }, [stdout]); useInput((input, key) => { - const wheelDirection = parseMouseWheelInput(input); - if (wheelDirection !== null) { - scrollBodyByRows( - wheelDirection === 'up' ? MOUSE_WHEEL_SCROLL_ROWS : -MOUSE_WHEEL_SCROLL_ROWS - ); + const wheel = parseMouseWheelEvent(input); + if (wheel !== null) { + notifyScroll(); + const target = resolveWheelTarget({ + mouseRow: wheel.row, + composerTop, + rows, + composerCanScroll + }); + if (target === 'composer') { + // A body-sized notch is near-full-page in a small composer; clamp it. + const step = Math.max(1, Math.min(MOUSE_WHEEL_SCROLL_ROWS, layout.composerVisibleRows - 1)); + scrollComposerByRows(wheel.direction === 'up' ? step : -step); + } else { + scrollBodyByRows( + wheel.direction === 'up' ? MOUSE_WHEEL_SCROLL_ROWS : -MOUSE_WHEEL_SCROLL_ROWS + ); + } + return; + } + + const click = parseMouseClickEvent(input); + if (click !== null) { + // Map the click to a cursor index + the scroll offset that keeps the + // visible window fixed (clicking repositions the caret without scrolling). + // Text rows start one row below composerTop (the top half-line cap); the + // prompt prefix offsets columns. + const composerState = store.get(composerStateAtom); + const result = resolveClickResult({ + text: composerState.text, + columns: Math.max(1, columns - PROMPT_PREFIX.length), + maxVisibleLines: layout.composerVisibleRows, + cursorIndex: composerState.cursorIndex, + offset: store.get(composerScrollOffsetRowsAtom), + visibleRow: click.row - 1 - (composerTop + COMPOSER_BACKGROUND_TOP_PADDING_ROWS), + column: click.column - 1 - PROMPT_PREFIX.length + }); + if (result !== null) { + store.set(setComposerCursorWithOffsetAtom, result); + } return; } if (key.pageUp) { + notifyScroll(); scrollBodyByRows(Math.max(1, layout.bodyRows - 2)); return; } if (key.pageDown) { + notifyScroll(); scrollBodyByRows(-Math.max(1, layout.bodyRows - 2)); return; } if (key.end) { + notifyScroll(); scrollBodyByRows(Number.NEGATIVE_INFINITY); } }); diff --git a/tui/src/components/HomeScreen/__tests__/wheelRouting.test.ts b/tui/src/components/HomeScreen/__tests__/wheelRouting.test.ts new file mode 100644 index 00000000..4563d5e5 --- /dev/null +++ b/tui/src/components/HomeScreen/__tests__/wheelRouting.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { isPointerOverComposer, resolveWheelTarget } from '@components/HomeScreen/wheelRouting.ts'; + +// rows = 24; composer block top at 0-based row 18; status row = 23. +const composerTop = 18; +const rows = 24; + +describe('isPointerOverComposer', () => { + it('is true from the composer top row down to just above the status row', () => { + expect(isPointerOverComposer(composerTop + 1, composerTop, rows)).toBe(true); // top cap + expect(isPointerOverComposer(rows - 1, composerTop, rows)).toBe(true); // bottom cap + }); + + it('is false one row above the composer block', () => { + expect(isPointerOverComposer(composerTop, composerTop, rows)).toBe(false); + }); + + it('is false on the header and on the status row', () => { + expect(isPointerOverComposer(1, composerTop, rows)).toBe(false); // header + expect(isPointerOverComposer(rows, composerTop, rows)).toBe(false); // status row + }); +}); + +describe('resolveWheelTarget', () => { + const over = { mouseRow: composerTop + 2, composerTop, rows }; + + it('routes to the composer when over it and it can scroll', () => { + expect(resolveWheelTarget({ ...over, composerCanScroll: true })).toBe('composer'); + }); + + it('falls through to the body when over a composer that cannot scroll', () => { + expect(resolveWheelTarget({ ...over, composerCanScroll: false })).toBe('body'); + }); + + it('defaults to the body for header/spacer/cwd and status rows', () => { + expect(resolveWheelTarget({ mouseRow: 2, composerTop, rows, composerCanScroll: true })).toBe('body'); + expect(resolveWheelTarget({ mouseRow: rows, composerTop, rows, composerCanScroll: true })).toBe('body'); + }); + + it('defaults to the body for an out-of-range pointer row', () => { + expect(resolveWheelTarget({ mouseRow: 9999, composerTop, rows, composerCanScroll: true })).toBe('body'); + }); +}); diff --git a/tui/src/components/HomeScreen/useCaretScrollSuppression.ts b/tui/src/components/HomeScreen/useCaretScrollSuppression.ts new file mode 100644 index 00000000..6d20b4f9 --- /dev/null +++ b/tui/src/components/HomeScreen/useCaretScrollSuppression.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useSetAtom } from 'jotai'; +import { caretSuppressedWhileScrollingAtom } from '@state/ui/composer/index.ts'; +import { CARET_SCROLL_SETTLE_MS } from '@constants/ui.ts'; + +/** + * Returns a `notifyScroll` callback that suppresses the composer caret while the + * user is actively scrolling and clears the suppression `settleMs` after the + * last scroll. This hides the caret during a scroll gesture and re-shows it + * (blinking steadily) once scrolling settles, instead of resetting the terminal + * cursor's blink on every scrolled frame. + */ +export function useCaretScrollSuppression(settleMs: number = CARET_SCROLL_SETTLE_MS): () => void { + const setSuppressed = useSetAtom(caretSuppressedWhileScrollingAtom); + const timeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }, + [] + ); + + return useCallback(() => { + setSuppressed(true); + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setSuppressed(false); + timeoutRef.current = null; + }, settleMs); + }, [setSuppressed, settleMs]); +} diff --git a/tui/src/components/HomeScreen/wheelRouting.ts b/tui/src/components/HomeScreen/wheelRouting.ts new file mode 100644 index 00000000..2b1dea69 --- /dev/null +++ b/tui/src/components/HomeScreen/wheelRouting.ts @@ -0,0 +1,34 @@ +export type WheelTarget = 'composer' | 'body'; + +/** + * Whether the wheel pointer (1-based SGR `mouseRow`) sits over the composer + * block. `composerTop` is the composer block's 0-based top row (the top + * half-line); the block spans down to but not including the status row + * (`rows - 1`). SGR rows are 1-based, so `mouseRow - 1` maps onto layout rows. + */ +export function isPointerOverComposer( + mouseRow: number, + composerTop: number, + rows: number +): boolean { + const pointerRow = mouseRow - 1; + return pointerRow >= composerTop && pointerRow < rows - 1; +} + +/** + * Routes a wheel notch to the pane under the pointer. The composer wins only + * when the pointer is over it AND it can actually scroll; every other case — + * header/spacer/cwd/status rows, a non-scrollable composer, or an + * out-of-range row — defaults to the body, so no notch is ever dropped. + */ +export function resolveWheelTarget(params: { + mouseRow: number; + composerTop: number; + rows: number; + composerCanScroll: boolean; +}): WheelTarget { + const { mouseRow, composerTop, rows, composerCanScroll } = params; + return composerCanScroll && isPointerOverComposer(mouseRow, composerTop, rows) + ? 'composer' + : 'body'; +} diff --git a/tui/src/components/PromptComposer/index.tsx b/tui/src/components/PromptComposer/index.tsx index 47bd8239..17315b24 100644 --- a/tui/src/components/PromptComposer/index.tsx +++ b/tui/src/components/PromptComposer/index.tsx @@ -7,16 +7,20 @@ import { PROMPT_PREFIX } from '@constants/ui.ts'; import { resolveComposerCursorPosition } from '@components/PromptComposer/cursorPosition.ts'; import { countVisibleComposerRows, - formatVisiblePrompt, - formatVisiblePromptView + formatVisiblePrompt } from '@components/PromptComposer/promptTextView.ts'; import { usePromptComposerInput } from '@components/PromptComposer/usePromptComposerInput.ts'; import { DEFAULT_COMPOSER_VISIBLE_LINES } from '@constants/ui.ts'; import { clearTranscriptAtom, enqueuePromptAtom } from '@state/promptQueue/index.ts'; import { openHelpAtom } from '@state/ui/help/index.ts'; import { PROMPT_MAX_BYTES } from '@libs/composer/promptText.ts'; -import { composerStateAtom } from '@state/ui/composer/index.ts'; -import { composerRowsAtom, composerTopAtom, layoutAtom } from '@state/ui/index.ts'; +import { resolveComposerWindow } from '@libs/composer/composerWindow.ts'; +import { + caretSuppressedWhileScrollingAtom, + composerScrollOffsetRowsAtom, + composerStateAtom +} from '@state/ui/composer/index.ts'; +import { composerRowsAtom, composerTopAtom, layoutAtom, scrollComposerCursorIntoViewAtom } from '@state/ui/index.ts'; import { columnsAtom, inputLockedAtom } from '@state/ui/index.ts'; type PromptComposerProps = { @@ -41,12 +45,21 @@ export function PromptComposer({ onVisibleRowsChange }: PromptComposerProps) { const state = useAtomValue(composerStateAtom); + const scrollOffsetRows = useAtomValue(composerScrollOffsetRowsAtom); + // Hide the caret while the user is actively scrolling (body or composer) and + // re-show it once scrolling settles, so the terminal cursor's blink is not + // reset on every scrolled frame. Subscribing here also re-renders the composer + // when suppression toggles, which re-asserts the caret after a scroll — Ink + // only draws the cursor on a frame where setCursorPosition ran (its cursorDirty + // flag resets each render). + const caretSuppressed = useAtomValue(caretSuppressedWhileScrollingAtom); const atomColumns = useAtomValue(columnsAtom); const atomInputLocked = useAtomValue(inputLockedAtom); const atomLayout = useAtomValue(layoutAtom); const atomComposerTop = useAtomValue(composerTopAtom); const enqueuePrompt = useSetAtom(enqueuePromptAtom); const setComposerRows = useSetAtom(composerRowsAtom); + const scrollCursorIntoView = useSetAtom(scrollComposerCursorIntoViewAtom); const { exit } = useApp(); const clearTranscript = useSetAtom(clearTranscriptAtom); const openHelp = useSetAtom(openHelpAtom); @@ -75,16 +88,18 @@ export function PromptComposer({ }); const inputColumns = Math.max(1, resolvedColumns - PROMPT_PREFIX.length); - const visiblePrompt = formatVisiblePromptView( - state.text, - inputColumns, - resolvedMaxVisibleLines, - state.cursorIndex - ); - const visibleText = visiblePrompt.text; + const composerWindow = resolveComposerWindow({ + text: state.text, + columns: inputColumns, + maxVisibleLines: resolvedMaxVisibleLines, + cursorIndex: state.cursorIndex, + offset: scrollOffsetRows + }); + const visibleText = composerWindow.text; + const visibleTextRows = visibleText.split('\n'); const shouldRenderBackground = true; const visibleRows = countVisibleComposerRows( - visibleText, + visibleTextRows.length, state.validationError !== null, shouldRenderBackground ); @@ -93,13 +108,28 @@ export function PromptComposer({ resolvedVisibleRowsChange?.(visibleRows); }, [resolvedVisibleRowsChange, visibleRows]); - if (resolvedIsActive && composerMetrics.hasMeasured) { + // Keep the caret visible after an edit or cursor move without snapping the + // view to the bottom: only a change that leaves the caret off-window scrolls + // (minimally, to the nearest edge). Keyed on text too, not just cursorIndex, + // because compound edits (e.g. bare Enter replacing a trailing `\` with a + // newline) change the text and the caret's row while leaving cursorIndex net + // unchanged. Wheel scrolling changes neither, so peeking is preserved. + useEffect(() => { + scrollCursorIntoView(); + }, [state.cursorIndex, state.text, scrollCursorIntoView]); + + if ( + resolvedIsActive && + composerMetrics.hasMeasured && + composerWindow.cursorVisible && + !caretSuppressed + ) { setCursorPosition( resolveComposerCursorPosition( visibleText, inputColumns, resolvedCursorTop ?? composerMetrics.top, - visiblePrompt.cursorIndex, + composerWindow.cursorIndex, shouldRenderBackground ) ); @@ -113,7 +143,7 @@ export function PromptComposer({ columns={resolvedColumns} shouldRenderBackground={shouldRenderBackground} validationError={state.validationError} - visibleTextRows={visibleText.split('\n')} + visibleTextRows={visibleTextRows} /> ); diff --git a/tui/src/components/PromptComposer/input/handleCursorMove.ts b/tui/src/components/PromptComposer/input/handleCursorMove.ts index 617162f0..3c9f8109 100644 --- a/tui/src/components/PromptComposer/input/handleCursorMove.ts +++ b/tui/src/components/PromptComposer/input/handleCursorMove.ts @@ -1,10 +1,18 @@ import type { ComposerKeyHandler } from '@components/PromptComposer/input/types.ts'; +import { PROMPT_PREFIX } from '@constants/ui.ts'; +import { columnsAtom } from '@state/ui/index.ts'; import { moveComposerCursorBackwardAtom, - moveComposerCursorForwardAtom + moveComposerCursorDownAtom, + moveComposerCursorForwardAtom, + moveComposerCursorUpAtom } from '@state/ui/composer/index.ts'; -/** Left/Right arrows move the composer cursor by one code point. */ +/** + * Left/Right move the composer cursor by one code point; Up/Down move between + * visual (wrapped) lines. The slash menu's Up/Down is handled earlier in the + * dispatcher (handleMenuKey), so these only fire when the menu is closed. + */ export const handleCursorMove: ComposerKeyHandler = (context) => { const { key, store } = context; @@ -18,5 +26,11 @@ export const handleCursorMove: ComposerKeyHandler = (context) => { return true; } + if (key.upArrow || key.downArrow) { + const columns = Math.max(1, store.get(columnsAtom) - PROMPT_PREFIX.length); + store.set(key.upArrow ? moveComposerCursorUpAtom : moveComposerCursorDownAtom, { columns }); + return true; + } + return false; }; diff --git a/tui/src/components/PromptComposer/promptTextView.ts b/tui/src/components/PromptComposer/promptTextView.ts index 17eda23e..7b30f8f6 100644 --- a/tui/src/components/PromptComposer/promptTextView.ts +++ b/tui/src/components/PromptComposer/promptTextView.ts @@ -1,11 +1,5 @@ import { COMPOSER_BACKGROUND_PADDING_ROWS } from '@constants/ui.ts'; -import { clamp } from '@libs/math/clamp.ts'; - -type WrappedPromptRow = { - text: string; - start: number; - end: number; -}; +import { resolveComposerWindow } from '@libs/composer/composerWindow.ts'; export type VisiblePromptView = { text: string; @@ -26,31 +20,17 @@ export function formatVisiblePromptView( maxVisibleLines: number, cursorIndex: number ): VisiblePromptView { - const safeColumns = Math.max(1, columns); - const safeMaxVisibleLines = Math.max(1, maxVisibleLines); - const rows = wrapText(text, safeColumns); - const safeCursorIndex = clamp(cursorIndex, 0, text.length); - const cursorRowIndex = resolveCursorRowIndex(rows, safeCursorIndex); - const lastVisibleStart = Math.max(0, rows.length - safeMaxVisibleLines); - // Keep the active cursor row visible by sliding the window upward only when - // the cursor would otherwise fall below the last visible composer row. - const visibleStart = clamp(cursorRowIndex - safeMaxVisibleLines + 1, 0, lastVisibleStart); - const visibleRows = rows.slice(visibleStart, visibleStart + safeMaxVisibleLines); - const visibleCursorIndex = resolveVisibleCursorIndex(visibleRows, safeCursorIndex); - - return { - text: visibleRows.map((row) => row.text).join('\n'), - cursorIndex: visibleCursorIndex - }; + const window = resolveComposerWindow({ text, columns, maxVisibleLines, cursorIndex }); + return { text: window.text, cursorIndex: window.cursorIndex }; } export function countVisibleComposerRows( - visibleText: string, + visibleRowCount: number, hasValidationError: boolean, hasBackgroundPadding: boolean ): number { return ( - visibleText.split('\n').length + + visibleRowCount + (hasValidationError ? 1 : 0) + (hasBackgroundPadding ? COMPOSER_BACKGROUND_PADDING_ROWS : 0) ); @@ -60,61 +40,3 @@ export function formatValidationError(error: string, columns: number, shouldPad: const errorLine = `ERROR: ${error}`; return shouldPad ? errorLine.padEnd(columns, ' ') : errorLine; } - -function wrapText(text: string, columns: number): WrappedPromptRow[] { - if (text.length === 0) { - return [{ text: '', start: 0, end: 0 }]; - } - - const rows: WrappedPromptRow[] = []; - let lineStart = 0; - - while (lineStart <= text.length) { - const newlineIndex = text.indexOf('\n', lineStart); - const lineEnd = newlineIndex < 0 ? text.length : newlineIndex; - const rawLine = text.slice(lineStart, lineEnd); - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; - - if (line.length === 0) { - rows.push({ text: '', start: lineStart, end: lineStart }); - } else { - for (let offset = 0; offset < line.length; offset += columns) { - const endOffset = Math.min(offset + columns, line.length); - rows.push({ - text: line.slice(offset, endOffset), - start: lineStart + offset, - end: lineStart + endOffset - }); - } - } - - if (newlineIndex < 0) { - break; - } - - lineStart = newlineIndex + 1; - } - - return rows; -} - -function resolveCursorRowIndex(rows: WrappedPromptRow[], cursorIndex: number): number { - return Math.max( - 0, - rows.findIndex((row) => cursorIndex >= row.start && cursorIndex <= row.end) - ); -} - -function resolveVisibleCursorIndex(rows: WrappedPromptRow[], cursorIndex: number): number { - let visibleCursorIndex = 0; - - for (const row of rows) { - if (cursorIndex >= row.start && cursorIndex <= row.end) { - return visibleCursorIndex + Math.min(row.text.length, cursorIndex - row.start); - } - - visibleCursorIndex += row.text.length + 1; - } - - return Math.max(0, visibleCursorIndex - 1); -} diff --git a/tui/src/constants/backend.ts b/tui/src/constants/backend.ts index 49020c00..685dda58 100644 --- a/tui/src/constants/backend.ts +++ b/tui/src/constants/backend.ts @@ -22,3 +22,12 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; /** Captured Cargo stderr is capped so a noisy build cannot exhaust memory. */ export const BUILD_STDERR_CAP_BYTES = 16 * 1024; + +/** + * Trailing-edge flush ceiling for streamed assistant tokens: the transcript + * re-renders at most once per this many ms (~15fps) rather than once per token. + * Windows with no new tokens emit nothing, so the effective rate floats down to + * the token arrival rate — a max-fps cap, not a forced cadence. See + * `createDeltaCoalescer`. + */ +export const STREAM_RENDER_FLUSH_MS = 66; diff --git a/tui/src/constants/ui.ts b/tui/src/constants/ui.ts index 4a6291a3..b212a1ed 100644 --- a/tui/src/constants/ui.ts +++ b/tui/src/constants/ui.ts @@ -29,6 +29,22 @@ export const INK_CURSOR_ROW_ORIGIN_OFFSET = 1; export const COMPOSER_BACKGROUND_PADDING_ROWS = 2; export const COMPOSER_BACKGROUND_TOP_PADDING_ROWS = 1; +/** + * Divisor bounding the composer's visible box to a fraction of the terminal + * height (`2` = at most half) so a long prompt cannot bury the transcript. The + * text-line cap derived from it subtracts the background padding and the + * reserved error row, so the whole composer box stays within `rows / DIVISOR`. + */ +export const COMPOSER_MAX_HEIGHT_DIVISOR = 2; + +/** + * Delay after the last scroll event before the composer re-shows its caret. The + * caret is suppressed while the user is actively scrolling so the terminal + * cursor's blink is not reset on every scrolled frame; it reappears (blinking + * steadily) once scrolling has settled for this long. + */ +export const CARET_SCROLL_SETTLE_MS = 100; + // --- Slash commands --- /** diff --git a/tui/src/contracts/backend/client.ts b/tui/src/contracts/backend/client.ts index 725156d9..0b951ea5 100644 --- a/tui/src/contracts/backend/client.ts +++ b/tui/src/contracts/backend/client.ts @@ -1,5 +1,3 @@ -import type { MessageSubmitParams, MessageSubmitResult } from '@contracts/backend/messages.ts'; - /** * Consumer-facing backend seam shared by the `@state` and `@backend` layers. * @@ -33,13 +31,39 @@ export class BackendClientError extends Error { } } +/** Params the TUI submits; the client generates the wire `turnId` internally. */ +export type StreamSubmitParams = { + text: string; +}; + +/** Callbacks invoked while a streamed turn is in flight. */ +export type StreamCallbacks = { + /** Called for each chunk of assistant text as it streams in. */ + onDelta: (delta: string) => void; +}; + +/** Terminal outcome of a streamed turn (transport failures reject instead). */ +export type StreamOutcome = + | { kind: 'completed'; text: string; finishReason: string | null } + | { kind: 'error'; errorKind: string; message: string } + | { kind: 'needsConfiguration' }; + /** - * Narrow backend seam the TUI uses for the first-slice ACK protocol. + * Narrow backend seam the TUI uses for streaming chat turns. * - * `submitMessage` resolves with the backend ACK result or rejects with a - * {@link BackendClientError}; display components depend only on this interface - * so process and protocol mechanics stay out of the render tree. + * `submitStreaming` streams assistant text through `callbacks.onDelta` and + * resolves with a {@link StreamOutcome} when the turn ends (completed, provider + * error, or needs-configuration). It rejects with a {@link BackendClientError} + * only for transport/timeout failures; display components depend only on this + * interface so process and protocol mechanics stay out of the render tree. */ export type BackendClient = { - submitMessage(params: MessageSubmitParams): Promise; + submitStreaming(params: StreamSubmitParams, callbacks: StreamCallbacks): Promise; + /** + * Fetches the workspace git status label (e.g. `⎇ main*`), or `null` when the + * workspace is not a git repository or `git` could not be queried. Rejects + * with a {@link BackendClientError} on transport/timeout failure. The backend + * formats the label; the TUI renders it verbatim. + */ + gitStatus(): Promise; }; diff --git a/tui/src/contracts/backend/messages.ts b/tui/src/contracts/backend/messages.ts index a048f46a..8d3c2580 100644 --- a/tui/src/contracts/backend/messages.ts +++ b/tui/src/contracts/backend/messages.ts @@ -15,11 +15,13 @@ export const MESSAGE_SUBMIT_METHOD = 'kqode.message.submit'; /** - * ACK text the first-slice Rust backend returns for a received prompt. + * KQode-owned JSON-RPC method returning the workspace git status label. * - * Must match the `ACK_MESSAGE` constant in `src/protocol.rs`. + * Must match `RpcMethod::GitStatus` (via `RpcMethod::as_str`) in + * `src/protocol.rs`. The backend runs `git` in the workspace and formats the + * label; the TUI renders the returned string verbatim. */ -export const ACK_MESSAGE = 'ACK: message received'; +export const GIT_STATUS_METHOD = 'kqode.git.status'; /** * JSON-RPC notification the backend emits exactly once, as soon as it is @@ -33,19 +35,77 @@ export const ACK_MESSAGE = 'ACK: message received'; export const BACKEND_READY_METHOD = 'kqode.backend.ready'; /** - * Params for `kqode.message.submit`; intentionally text-only for this slice. + * Server→client notification carrying one chunk of streamed assistant text. + * Must match `TOKEN_DELTA_METHOD` in `src/protocol.rs`. + */ +export const TOKEN_DELTA_METHOD = 'kqode/tokenDelta'; + +/** + * Server→client notification marking the natural end of a streamed turn. + * Must match `TURN_END_METHOD` in `src/protocol.rs`. + */ +export const TURN_END_METHOD = 'kqode/turnEnd'; + +/** + * Server→client notification for a turn that ended with a provider error. + * Must match `TURN_ERROR_METHOD` in `src/protocol.rs`. + */ +export const TURN_ERROR_METHOD = 'kqode/turnError'; + +/** `status` value when a submit is accepted and streaming has begun. */ +export const SUBMIT_STATUS_STREAMING = 'streaming'; + +/** `status` value when a submit cannot run because no API key is configured. */ +export const SUBMIT_STATUS_NEEDS_CONFIGURATION = 'needsConfiguration'; + +/** + * Params for `kqode.message.submit`. * - * The Rust backend deserializes this with serde `#[serde(deny_unknown_fields)]` - * (`MessageSubmitParams` in `src/protocol.rs`), so adding a field here without - * updating the Rust struct makes the backend reject the request as invalid - * params. Keep the two shapes in lockstep. + * `turnId` is generated by the client so it can register notification handlers + * before sending, correlating streamed events without depending on ack ordering. + * The Rust backend deserializes this with `#[serde(deny_unknown_fields)]` + * (`MessageSubmitParams` in `src/protocol.rs`), so keep the two shapes in + * lockstep. */ export type MessageSubmitParams = { text: string; + turnId: string; }; -/** Result for `kqode.message.submit`. */ +/** + * Result for `kqode.message.submit`: an immediate streaming ack. `status` is one + * of {@link SUBMIT_STATUS_STREAMING} or {@link SUBMIT_STATUS_NEEDS_CONFIGURATION}. + */ export type MessageSubmitResult = { + turnId: string; + status: string; +}; + +/** Payload for {@link TOKEN_DELTA_METHOD}. */ +export type TokenDeltaParams = { + turnId: string; + delta: string; +}; + +/** Payload for {@link TURN_END_METHOD}. */ +export type TurnEndParams = { + turnId: string; + finishReason: string | null; +}; + +/** Payload for {@link TURN_ERROR_METHOD}. */ +export type TurnErrorParams = { + turnId: string; + errorKind: string; message: string; - receivedText: string; +}; + +/** + * Result for `kqode.git.status`: the formatted working-tree label (e.g. + * `⎇ main*`), or `null` when the workspace is not a git repository or `git` + * could not be queried. The Rust backend owns parsing and formatting + * (`GitStatusResult` in `src/protocol.rs`); keep the two shapes in lockstep. + */ +export type GitStatusResult = { + label: string | null; }; diff --git a/tui/src/libs/composer/__tests__/composerWindow.test.ts b/tui/src/libs/composer/__tests__/composerWindow.test.ts new file mode 100644 index 00000000..0a1f6ff8 --- /dev/null +++ b/tui/src/libs/composer/__tests__/composerWindow.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveClickResult, + resolveComposerWindow, + resolveScrollIntoViewOffset, + resolveVerticalCursorIndex +} from '@libs/composer/composerWindow.ts'; + +describe('resolveComposerWindow', () => { + // 6 wrapped rows at columns=4: '0000','1111','2222','3333','4444','5555' + const text = '000011112222333344445555'; + const columns = 4; + const maxVisibleLines = 3; + const atEnd = { text, columns, maxVisibleLines, cursorIndex: text.length }; + + it('follows the cursor at offset 0 (bottom window when cursor at end)', () => { + const window = resolveComposerWindow(atEnd); + expect(window.text).toBe('3333\n4444\n5555'); + expect(window.cursorVisible).toBe(true); + expect(window.canScroll).toBe(true); + }); + + it('reveals earlier rows with a positive offset, hiding the cursor row', () => { + const window = resolveComposerWindow({ ...atEnd, offset: 3 }); + expect(window.text).toBe('0000\n1111\n2222'); + expect(window.cursorVisible).toBe(false); + }); + + it('clamps a stale over-large offset to the top row', () => { + expect(resolveComposerWindow({ ...atEnd, offset: 999 }).text).toBe('0000\n1111\n2222'); + }); + + it('exposes clamp bounds spanning the full range (cursor at end)', () => { + const window = resolveComposerWindow(atEnd); + expect(window.maxOffset).toBe(3); // baseStart = lastStart = 6 - 3 + expect(window.minOffset).toBe(0); // baseStart - lastStart + }); + + it('reports canScroll false when the prompt fits', () => { + const window = resolveComposerWindow({ text: 'short', columns: 20, maxVisibleLines: 3, cursorIndex: 5 }); + expect(window.canScroll).toBe(false); + }); +}); + +describe('resolveScrollIntoViewOffset', () => { + // 6 wrapped rows at columns 4: '0000'(0-4) '1111'(4-8) '2222'(8-12) '3333'(12-16) '4444'(16-20) '5555'(20-24) + const text = '000011112222333344445555'; + const columns = 4; + const maxVisibleLines = 3; + + it('keeps a scrolled offset when the cursor is already visible', () => { + // cursor on row 2 (index 10); offset -1 shows rows 1..3 with the cursor visible + expect(resolveScrollIntoViewOffset({ text, columns, maxVisibleLines, cursorIndex: 10, offset: -1 })).toBe(-1); + }); + + it('scrolls to the bottom edge when the cursor is below the window', () => { + // offset 3 shows rows 0..2 but the cursor (row 5) is below -> pin to bottom (offset 0) + expect(resolveScrollIntoViewOffset({ text, columns, maxVisibleLines, cursorIndex: 24, offset: 3 })).toBe(0); + }); + + it('scrolls to the top edge when the cursor is above the window', () => { + // cursor on row 0; offset -2 shows rows 2..4 (cursor above) -> pin to top (offset 0) + expect(resolveScrollIntoViewOffset({ text, columns, maxVisibleLines, cursorIndex: 0, offset: -2 })).toBe(0); + }); +}); + +describe('resolveVerticalCursorIndex', () => { + // 'aaa\nbbb\nccc' -> rows 'aaa'(0-3), 'bbb'(4-7), 'ccc'(8-11) + const text = 'aaa\nbbb\nccc'; + + it('moves up a visual line preserving the column', () => { + expect(resolveVerticalCursorIndex(text, 40, 11, 'up')).toBe(7); // row2 col3 -> row1 col3 + }); + + it('moves down a visual line preserving the column', () => { + expect(resolveVerticalCursorIndex(text, 40, 7, 'down')).toBe(11); // row1 col3 -> row2 col3 + }); + + it('clamps the column to a shorter target row', () => { + // 'aa\nb' -> 'aa'(0-2), 'b'(3-4); row0 col2 down -> row1 (len 1) -> index 4 + expect(resolveVerticalCursorIndex('aa\nb', 40, 2, 'down')).toBe(4); + }); + + it('returns null at the first row (up) and last row (down)', () => { + expect(resolveVerticalCursorIndex(text, 40, 1, 'up')).toBeNull(); + expect(resolveVerticalCursorIndex(text, 40, 11, 'down')).toBeNull(); + }); + + it('returns null for a single-row prompt', () => { + expect(resolveVerticalCursorIndex('hello', 40, 3, 'up')).toBeNull(); + expect(resolveVerticalCursorIndex('hello', 40, 3, 'down')).toBeNull(); + }); + + it('navigates wrapped rows of a single logical line', () => { + // 'abcdefghij' cols 4 -> 'abcd'(0-4), 'efgh'(4-8), 'ij'(8-10) + expect(resolveVerticalCursorIndex('abcdefghij', 4, 2, 'down')).toBe(6); + expect(resolveVerticalCursorIndex('abcdefghij', 4, 6, 'up')).toBe(2); + }); +}); + +describe('resolveClickResult', () => { + // 'aaa\nbbb\nccc' -> rows 'aaa'(0-3), 'bbb'(4-7), 'ccc'(8-11); all visible at maxVisibleLines 3 + const base = { text: 'aaa\nbbb\nccc', columns: 40, maxVisibleLines: 3, cursorIndex: 11, offset: 0 }; + + it('maps a click on a visible row + column to the absolute index', () => { + expect(resolveClickResult({ ...base, visibleRow: 0, column: 1 })?.index).toBe(1); + expect(resolveClickResult({ ...base, visibleRow: 1, column: 2 })?.index).toBe(6); + }); + + it('clamps the column to the clicked row length (and floors at 0)', () => { + expect(resolveClickResult({ ...base, visibleRow: 2, column: 99 })?.index).toBe(11); + expect(resolveClickResult({ ...base, visibleRow: 0, column: -5 })?.index).toBe(0); + }); + + it('returns null for rows outside the visible window', () => { + expect(resolveClickResult({ ...base, visibleRow: -1, column: 0 })).toBeNull(); + expect(resolveClickResult({ ...base, visibleRow: 3, column: 0 })).toBeNull(); + }); + + it('accounts for the scroll offset when mapping the clicked row', () => { + // 6 single-char rows; offset 2 shows rows 1..3, so visibleRow 0 -> wrap row 1 ('1' at index 2) + const scrolled = { text: '0\n1\n2\n3\n4\n5', columns: 40, maxVisibleLines: 3, cursorIndex: 11, offset: 2 }; + expect(resolveClickResult({ ...scrolled, visibleRow: 0, column: 0 })?.index).toBe(2); + }); + + it('returns an offset that keeps the visible window fixed (no scroll on click)', () => { + // 6 single-char rows; offset 2 shows rows 1..3. Clicking the top visible row + // must leave those same rows visible after the caret moves there. + const scrolled = { text: '0\n1\n2\n3\n4\n5', columns: 40, maxVisibleLines: 3, cursorIndex: 11, offset: 2 }; + const before = resolveComposerWindow(scrolled).text; + const result = resolveClickResult({ ...scrolled, visibleRow: 0, column: 0 }); + expect(result).not.toBeNull(); + if (result === null) { + return; + } + const after = resolveComposerWindow({ + text: scrolled.text, + columns: scrolled.columns, + maxVisibleLines: scrolled.maxVisibleLines, + cursorIndex: result.index, + offset: result.offset + }).text; + expect(after).toBe(before); + }); + + it('keeps the window fixed when clicking a soft-wrapped row at a wrap boundary', () => { + // 'abcdefghij' at columns 2 -> rows ab(0-2) cd(2-4) ef(4-6) gh(6-8) ij(8-10). + // offset 2 shows cd/ef (visibleStart 1). A column-0 click lands on a wrap + // boundary index (row.start === previousRow.end); the window must not scroll. + const scrolled = { text: 'abcdefghij', columns: 2, maxVisibleLines: 2, cursorIndex: 10, offset: 2 }; + const before = resolveComposerWindow(scrolled).text; + expect(before).toBe('cd\nef'); + for (const visibleRow of [0, 1]) { + const result = resolveClickResult({ ...scrolled, visibleRow, column: 0 }); + expect(result).not.toBeNull(); + if (result === null) { + continue; + } + const after = resolveComposerWindow({ + text: scrolled.text, + columns: scrolled.columns, + maxVisibleLines: scrolled.maxVisibleLines, + cursorIndex: result.index, + offset: result.offset + }).text; + expect(after).toBe(before); + } + }); +}); diff --git a/tui/src/libs/composer/__tests__/wrapPromptText.test.ts b/tui/src/libs/composer/__tests__/wrapPromptText.test.ts new file mode 100644 index 00000000..6893c5cb --- /dev/null +++ b/tui/src/libs/composer/__tests__/wrapPromptText.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { countWrappedPromptRows, wrapPromptText } from '@libs/composer/wrapPromptText.ts'; + +describe('wrapPromptText', () => { + it('wraps a long logical line every `columns` characters', () => { + expect(wrapPromptText('abcdefghij', 4).map((row) => row.text)).toEqual(['abcd', 'efgh', 'ij']); + }); + + it('keeps authored newlines as separate rows', () => { + expect(wrapPromptText('a\nbb', 10).map((row) => row.text)).toEqual(['a', 'bb']); + }); + + it('returns a single empty row for empty text', () => { + expect(wrapPromptText('', 10)).toEqual([{ text: '', start: 0, end: 0 }]); + }); + + it('counts wrapped rows', () => { + expect(countWrappedPromptRows('abcdefghij', 4)).toBe(3); + }); + + it('reuses the cached row array for repeated identical inputs', () => { + const first = wrapPromptText('cache\nme', 10); + const second = wrapPromptText('cache\nme', 10); + + expect(second).toBe(first); + }); + + it('recomputes when the text or width changes', () => { + const base = wrapPromptText('abcdef', 4); + const sameAgain = wrapPromptText('abcdef', 4); + const widerWidth = wrapPromptText('abcdef', 6); + + expect(sameAgain).toBe(base); + expect(widerWidth).not.toBe(base); + }); +}); diff --git a/tui/src/libs/composer/composerWindow.ts b/tui/src/libs/composer/composerWindow.ts new file mode 100644 index 00000000..d46fda30 --- /dev/null +++ b/tui/src/libs/composer/composerWindow.ts @@ -0,0 +1,196 @@ +import { clamp } from '@libs/math/clamp.ts'; +import { wrapPromptText } from '@libs/composer/wrapPromptText.ts'; +import type { WrappedPromptRow } from '@libs/composer/wrapPromptText.ts'; + +export type ComposerWindowParams = { + text: string; + /** Prompt input width (terminal columns minus the prompt prefix). */ + columns: number; + maxVisibleLines: number; + cursorIndex: number; + /** Signed rows scrolled away from the cursor-follow baseline (+ up, - down). */ + offset?: number; +}; + +export type ComposerWindow = { + /** The visible wrapped rows joined by newlines. */ + text: string; + /** Cursor index within `text` (only meaningful when `cursorVisible`). */ + cursorIndex: number; + /** Whether the cursor's row is inside the visible window. */ + cursorVisible: boolean; + /** Whether the prompt overflows the visible height (cursor-independent). */ + canScroll: boolean; + /** Most-negative offset — scrolls to the last wrapped row. */ + minOffset: number; + /** Most-positive offset — scrolls to the first wrapped row. */ + maxOffset: number; +}; + +/** + * Resolves the visible window of a (possibly scrolled) multi-line prompt. + * + * `offset` is a signed number of rows away from the cursor-follow baseline: + * positive scrolls toward earlier rows (up), negative toward later rows (down), + * and `0` reproduces the cursor-follow window. `visibleStart` is always clamped + * into `[0, lastStart]`, so a stale offset (after a resize/edit) stays safe and + * self-heals. `minOffset`/`maxOffset` are the clamp bounds a scroll action reuses. + */ +export function resolveComposerWindow(params: ComposerWindowParams): ComposerWindow { + const { text, columns, maxVisibleLines, cursorIndex, offset = 0 } = params; + const safeMaxVisibleLines = Math.max(1, maxVisibleLines); + const rows = wrapPromptText(text, columns); + const safeCursorIndex = clamp(cursorIndex, 0, text.length); + // Cursor-follow baseline (slide up only when the cursor would fall below the + // last visible row); the signed offset then shifts the window from there. + const { visibleStart, lastStart, baseStart, cursorRowIndex } = resolveWindowBounds( + rows, + safeMaxVisibleLines, + safeCursorIndex, + offset + ); + const visibleRows = rows.slice(visibleStart, visibleStart + safeMaxVisibleLines); + + return { + text: visibleRows.map((row) => row.text).join('\n'), + cursorIndex: resolveVisibleCursorIndex(visibleRows, safeCursorIndex), + cursorVisible: + cursorRowIndex >= visibleStart && cursorRowIndex < visibleStart + safeMaxVisibleLines, + canScroll: rows.length > safeMaxVisibleLines, + minOffset: baseStart - lastStart, + maxOffset: baseStart + }; +} + +/** + * Returns the scroll offset that brings the cursor into the visible window with + * the least movement: the current `offset` when the cursor is already visible, + * or the offset that pins the cursor to the nearest window edge otherwise. Used + * to keep the caret visible after an edit or navigation without snapping the + * view to the bottom (offset `0`). + */ +export function resolveScrollIntoViewOffset(params: ComposerWindowParams): number { + const { text, columns, maxVisibleLines, cursorIndex, offset = 0 } = params; + const safeMaxVisibleLines = Math.max(1, maxVisibleLines); + const rows = wrapPromptText(text, columns); + const safeCursorIndex = clamp(cursorIndex, 0, text.length); + const { visibleStart, baseStart, cursorRowIndex } = resolveWindowBounds( + rows, + safeMaxVisibleLines, + safeCursorIndex, + offset + ); + + if (cursorRowIndex < visibleStart) { + return baseStart - cursorRowIndex; // above the window: pin to the top row + } + if (cursorRowIndex >= visibleStart + safeMaxVisibleLines) { + return baseStart - (cursorRowIndex - safeMaxVisibleLines + 1); // below: pin to the bottom row + } + return offset; // already visible — keep the current view +} + +/** + * Maps a click on visible row `visibleRow` (0-based within the window) at + * `column` (0-based within that row's text) to the resulting cursor `index` and + * the scroll `offset` that keeps the current visible window fixed — so clicking + * repositions the caret without scrolling the composer. Returns `null` when the + * click lands outside the visible wrapped rows. + */ +export function resolveClickResult( + params: ComposerWindowParams & { visibleRow: number; column: number } +): { index: number; offset: number } | null { + const { text, columns, maxVisibleLines, cursorIndex, offset = 0, visibleRow, column } = params; + const safeMaxVisibleLines = Math.max(1, maxVisibleLines); + if (visibleRow < 0 || visibleRow >= safeMaxVisibleLines) { + return null; + } + + const rows = wrapPromptText(text, columns); + const safeCursorIndex = clamp(cursorIndex, 0, text.length); + const { visibleStart, lastStart } = resolveWindowBounds( + rows, + safeMaxVisibleLines, + safeCursorIndex, + offset + ); + const targetRowIndex = visibleStart + visibleRow; + if (targetRowIndex >= rows.length) { + return null; + } + + const row = rows[targetRowIndex]; + const index = row.start + Math.min(Math.max(0, column), row.end - row.start); + // Keep the window fixed: reproduce the current visibleStart against the NEW + // cursor's follow baseline. Resolve the cursor row the same way + // resolveComposerWindow does — at a soft-wrap boundary `index === row.start === + // previousRow.end` resolves to the earlier row — so the two agree and the + // window does not shift. + const baseStart = clamp( + resolveCursorRowIndex(rows, index) - safeMaxVisibleLines + 1, + 0, + lastStart + ); + return { index, offset: baseStart - visibleStart }; +} + +function resolveWindowBounds( + rows: WrappedPromptRow[], + safeMaxVisibleLines: number, + cursorIndex: number, + offset: number +): { visibleStart: number; lastStart: number; baseStart: number; cursorRowIndex: number } { + const cursorRowIndex = resolveCursorRowIndex(rows, cursorIndex); + const lastStart = Math.max(0, rows.length - safeMaxVisibleLines); + const baseStart = clamp(cursorRowIndex - safeMaxVisibleLines + 1, 0, lastStart); + const visibleStart = clamp(baseStart - offset, 0, lastStart); + return { visibleStart, lastStart, baseStart, cursorRowIndex }; +} + +function resolveCursorRowIndex(rows: WrappedPromptRow[], cursorIndex: number): number { + return Math.max( + 0, + rows.findIndex((row) => cursorIndex >= row.start && cursorIndex <= row.end) + ); +} + +export type VerticalDirection = 'up' | 'down'; + +/** + * The cursor index one visual row up or down from `cursorIndex`, preserving the + * visual column (clamped to the target row's length). Returns `null` when there + * is no visual row that direction (first/last row, or a single-row prompt) — the + * caller treats that as a no-op, leaving a seam for future history traversal. + */ +export function resolveVerticalCursorIndex( + text: string, + columns: number, + cursorIndex: number, + direction: VerticalDirection +): number | null { + const rows = wrapPromptText(text, columns); + const safeCursorIndex = clamp(cursorIndex, 0, text.length); + const currentRow = resolveCursorRowIndex(rows, safeCursorIndex); + const targetRow = currentRow + (direction === 'up' ? -1 : 1); + if (targetRow < 0 || targetRow >= rows.length) { + return null; + } + + const column = safeCursorIndex - rows[currentRow].start; + const target = rows[targetRow]; + return target.start + Math.min(column, target.end - target.start); +} + +function resolveVisibleCursorIndex(rows: WrappedPromptRow[], cursorIndex: number): number { + let visibleCursorIndex = 0; + + for (const row of rows) { + if (cursorIndex >= row.start && cursorIndex <= row.end) { + return visibleCursorIndex + Math.min(row.text.length, cursorIndex - row.start); + } + + visibleCursorIndex += row.text.length + 1; + } + + return Math.max(0, visibleCursorIndex - 1); +} diff --git a/tui/src/libs/composer/wrapPromptText.ts b/tui/src/libs/composer/wrapPromptText.ts new file mode 100644 index 00000000..36811b49 --- /dev/null +++ b/tui/src/libs/composer/wrapPromptText.ts @@ -0,0 +1,80 @@ +/** One wrapped visual row of the prompt, with its source index range. */ +export type WrappedPromptRow = { + text: string; + start: number; + end: number; +}; + +// Single-slot memo: the prompt is re-wrapped several times per keystroke (the +// render window, the scroll-into-view effect, and the scroll atoms), always for +// the current text and width. Caching the last result — compared with `===` +// (value equality, with V8's same-string-object fast path) rather than a Map +// hash — collapses those into a single wrap. Callers never mutate the returned +// rows, so sharing the cached array is safe. +let cachedText: string | undefined; +let cachedColumns = -1; +let cachedRows: WrappedPromptRow[] | undefined; + +/** + * Splits `text` into visual rows: authored newlines start new rows, and each + * logical line is chunked every `columns` characters. An empty prompt yields a + * single empty row. Pass the prompt's input width (terminal columns minus the + * prompt prefix) so wrapping matches what the composer renders. + * + * The last `(text, columns)` result is memoized, so the repeated wraps of the + * current prompt within a keystroke reuse one computation. + */ +export function wrapPromptText(text: string, columns: number): WrappedPromptRow[] { + const safeColumns = Math.max(1, columns); + if (cachedRows !== undefined && cachedText === text && cachedColumns === safeColumns) { + return cachedRows; + } + + const rows = computeWrappedPromptRows(text, safeColumns); + cachedText = text; + cachedColumns = safeColumns; + cachedRows = rows; + return rows; +} + +/** Number of visual rows `text` wraps into at `columns` width. */ +export function countWrappedPromptRows(text: string, columns: number): number { + return wrapPromptText(text, columns).length; +} + +function computeWrappedPromptRows(text: string, safeColumns: number): WrappedPromptRow[] { + if (text.length === 0) { + return [{ text: '', start: 0, end: 0 }]; + } + + const rows: WrappedPromptRow[] = []; + let lineStart = 0; + + while (lineStart <= text.length) { + const newlineIndex = text.indexOf('\n', lineStart); + const lineEnd = newlineIndex < 0 ? text.length : newlineIndex; + const rawLine = text.slice(lineStart, lineEnd); + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + + if (line.length === 0) { + rows.push({ text: '', start: lineStart, end: lineStart }); + } else { + for (let offset = 0; offset < line.length; offset += safeColumns) { + const endOffset = Math.min(offset + safeColumns, line.length); + rows.push({ + text: line.slice(offset, endOffset), + start: lineStart + offset, + end: lineStart + endOffset + }); + } + } + + if (newlineIndex < 0) { + break; + } + + lineStart = newlineIndex + 1; + } + + return rows; +} diff --git a/tui/src/libs/git/__tests__/gitStatus.test.ts b/tui/src/libs/git/__tests__/gitStatus.test.ts deleted file mode 100644 index b4a395f2..00000000 --- a/tui/src/libs/git/__tests__/gitStatus.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { formatGitStatusLabel, parseGitStatus } from '@libs/git/gitStatus.ts'; - -describe('git status labels', () => { - it('formats branch labels with staged, unstaged, and untracked flags', () => { - const status = parseGitStatus( - [ - '## feat/first-ink-tui-jsonrpc-backend...origin/feat/first-ink-tui-jsonrpc-backend', - ' M tui/src/App.tsx', - 'A tui/src/libs/git/gitStatus.ts', - '?? tui/src/libs/git/__tests__/gitStatus.test.ts' - ].join('\n') - ); - - expect(formatGitStatusLabel(status)).toBe('⎇ feat/first-ink-tui-jsonrpc-backend*+%'); - }); - - it('returns a clean branch label when the worktree has no changes', () => { - const status = parseGitStatus('## main...origin/main\n'); - - expect(formatGitStatusLabel(status)).toBe('⎇ main'); - }); -}); diff --git a/tui/src/libs/git/gitStatus.ts b/tui/src/libs/git/gitStatus.ts deleted file mode 100644 index b94f3752..00000000 --- a/tui/src/libs/git/gitStatus.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {execFileSync} from 'node:child_process'; - -const GIT_BRANCH_ICON = '⎇'; -const UNSTAGED_CHANGE_FLAG = '*'; -const STAGED_CHANGE_FLAG = '+'; -const UNTRACKED_CHANGE_FLAG = '%'; -const STATUS_BRANCH_PREFIX = '## '; -const STATUS_UPSTREAM_SEPARATOR = '...'; -const NO_COMMITS_BRANCH_PREFIX = 'No commits yet on '; -const DETACHED_HEAD_STATUS = 'HEAD (no branch)'; -const GIT_STATUS_TIMEOUT_MS = 2_000; - -export type GitStatus = { - branch: string; - hasUnstagedChanges: boolean; - hasStagedChanges: boolean; - hasUntrackedChanges: boolean; -}; - -export function readGitStatusLabel(cwd: string): string | undefined { - try { - const porcelainStatus = execFileSync( - 'git', - ['-C', cwd, 'status', '--porcelain=v1', '--branch'], - { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: GIT_STATUS_TIMEOUT_MS - } - ); - - return formatGitStatusLabel(parseGitStatus(porcelainStatus)); - } catch { - return undefined; - } -} - -export function formatGitStatusLabel(status: GitStatus | undefined): string | undefined { - if (status === undefined) { - return undefined; - } - - return `${GIT_BRANCH_ICON} ${status.branch}${formatGitStatusFlags(status)}`; -} - -export function parseGitStatus(porcelainStatus: string): GitStatus | undefined { - const lines = porcelainStatus.split(/\r?\n/).filter(Boolean); - const branchLine = lines.find((line) => line.startsWith(STATUS_BRANCH_PREFIX)); - const branch = parseBranchName(branchLine); - - if (branch === undefined) { - return undefined; - } - - return lines.reduce( - (status, line) => { - if (line.startsWith(STATUS_BRANCH_PREFIX)) { - return status; - } - - return { - branch: status.branch, - hasUnstagedChanges: - status.hasUnstagedChanges || (line[1] !== ' ' && line[1] !== '?' && line[1] !== '!'), - hasStagedChanges: - status.hasStagedChanges || (line[0] !== ' ' && line[0] !== '?' && line[0] !== '!'), - hasUntrackedChanges: status.hasUntrackedChanges || line.startsWith('??') - }; - }, - { - branch, - hasUnstagedChanges: false, - hasStagedChanges: false, - hasUntrackedChanges: false - } - ); -} - -function parseBranchName(branchLine: string | undefined): string | undefined { - if (branchLine === undefined) { - return undefined; - } - - const branchStatus = branchLine.slice(STATUS_BRANCH_PREFIX.length); - - if (branchStatus.startsWith(NO_COMMITS_BRANCH_PREFIX)) { - return branchStatus.slice(NO_COMMITS_BRANCH_PREFIX.length); - } - - if (branchStatus === DETACHED_HEAD_STATUS) { - return 'HEAD'; - } - - return branchStatus.split(STATUS_UPSTREAM_SEPARATOR)[0].split(' [')[0]; -} - -function formatGitStatusFlags(status: GitStatus): string { - return [ - status.hasUnstagedChanges ? UNSTAGED_CHANGE_FLAG : '', - status.hasStagedChanges ? STAGED_CHANGE_FLAG : '', - status.hasUntrackedChanges ? UNTRACKED_CHANGE_FLAG : '' - ].join(''); -} diff --git a/tui/src/libs/promptQueue/__tests__/promptQueue.test.ts b/tui/src/libs/promptQueue/__tests__/promptQueue.test.ts new file mode 100644 index 00000000..45189d42 --- /dev/null +++ b/tui/src/libs/promptQueue/__tests__/promptQueue.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { BodyEntryKind } from '@constants/bodyEntry.ts'; +import { queueToBodyEntries } from '@libs/promptQueue/promptQueue.ts'; +import type { QueueItem } from '@libs/promptQueue/promptQueue.ts'; + +describe('queueToBodyEntries memoization', () => { + it('returns identical entry references for an unchanged item', () => { + const queue: QueueItem[] = [{ id: 1, text: 'hello', state: 'active' }]; + + const first = queueToBodyEntries(queue); + const second = queueToBodyEntries(queue); + + expect(second[0]).toBe(first[0]); + }); + + it('reuses both entries for a settled item across calls', () => { + const item: QueueItem = { + id: 2, + text: 'done', + state: 'settled', + result: { kind: BodyEntryKind.Assistant, text: 'reply' } + }; + + const first = queueToBodyEntries([item]); + const second = queueToBodyEntries([item]); + + expect(second[0]).toBe(first[0]); + expect(second[1]).toBe(first[1]); + expect(first[1]).toMatchObject({ kind: BodyEntryKind.Assistant, text: 'reply' }); + }); + + it('rebuilds the streaming entry when its text grows', () => { + const item: QueueItem = { id: 3, text: 'ask', state: 'active' }; + + const partial = queueToBodyEntries([item], new Map([[3, 'he']])); + const more = queueToBodyEntries([item], new Map([[3, 'hello']])); + + expect(more[1]).not.toBe(partial[1]); + expect(more[1]).toMatchObject({ kind: BodyEntryKind.Assistant, text: 'hello' }); + }); + + it('does not share cached entries between distinct item objects', () => { + const a: QueueItem = { id: 4, text: 'x', state: 'active' }; + const b: QueueItem = { id: 4, text: 'x', state: 'active' }; + + const entriesA = queueToBodyEntries([a]); + const entriesB = queueToBodyEntries([b]); + + expect(entriesB[0]).not.toBe(entriesA[0]); + expect(entriesB[0]).toEqual(entriesA[0]); + }); +}); diff --git a/tui/src/libs/promptQueue/__tests__/streamCoalescer.test.ts b/tui/src/libs/promptQueue/__tests__/streamCoalescer.test.ts new file mode 100644 index 00000000..10ffbd07 --- /dev/null +++ b/tui/src/libs/promptQueue/__tests__/streamCoalescer.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDeltaCoalescer } from '@libs/promptQueue/streamCoalescer.ts'; + +const INTERVAL_MS = 66; + +describe('createDeltaCoalescer', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('coalesces deltas within one interval into a single emit', () => { + const batches: string[] = []; + const coalescer = createDeltaCoalescer((batch) => batches.push(batch), INTERVAL_MS); + + coalescer.push('a'); + coalescer.push('b'); + coalescer.push('c'); + expect(batches).toEqual([]); + + vi.advanceTimersByTime(INTERVAL_MS); + expect(batches).toEqual(['abc']); + }); + + it('starts a fresh batch after each flush window', () => { + const batches: string[] = []; + const coalescer = createDeltaCoalescer((batch) => batches.push(batch), INTERVAL_MS); + + coalescer.push('a'); + vi.advanceTimersByTime(INTERVAL_MS); + coalescer.push('b'); + vi.advanceTimersByTime(INTERVAL_MS); + + expect(batches).toEqual(['a', 'b']); + }); + + it('flush() emits buffered text immediately and clears the pending timer', () => { + const batches: string[] = []; + const coalescer = createDeltaCoalescer((batch) => batches.push(batch), INTERVAL_MS); + + coalescer.push('x'); + coalescer.flush(); + expect(batches).toEqual(['x']); + + vi.advanceTimersByTime(INTERVAL_MS); + expect(batches).toEqual(['x']); + }); + + it('cancel() discards buffered text without emitting', () => { + const batches: string[] = []; + const coalescer = createDeltaCoalescer((batch) => batches.push(batch), INTERVAL_MS); + + coalescer.push('y'); + coalescer.cancel(); + + vi.advanceTimersByTime(INTERVAL_MS); + expect(batches).toEqual([]); + }); + + it('ignores empty deltas', () => { + const batches: string[] = []; + const coalescer = createDeltaCoalescer((batch) => batches.push(batch), INTERVAL_MS); + + coalescer.push(''); + vi.advanceTimersByTime(INTERVAL_MS); + expect(batches).toEqual([]); + }); +}); diff --git a/tui/src/libs/promptQueue/promptQueue.ts b/tui/src/libs/promptQueue/promptQueue.ts index 29643ef9..f23f3447 100644 --- a/tui/src/libs/promptQueue/promptQueue.ts +++ b/tui/src/libs/promptQueue/promptQueue.ts @@ -1,4 +1,5 @@ import { BackendClientError } from '@contracts/backend/index.ts'; +import type { StreamOutcome } from '@contracts/backend/index.ts'; import { BodyEntryKind } from '@constants/bodyEntry.ts'; import { sanitizeDisplayText } from '@libs/text/sanitizeDisplayText.ts'; import type { BodyEntry } from '@libs/tui/bodyRows.ts'; @@ -6,13 +7,20 @@ import type { BodyEntry } from '@libs/tui/bodyRows.ts'; export type QueueItemState = 'active' | 'queued' | 'settled'; export type BackendResult = { - kind: typeof BodyEntryKind.Success | typeof BodyEntryKind.Error; + kind: + | typeof BodyEntryKind.Assistant + | typeof BodyEntryKind.Success + | typeof BodyEntryKind.Error; text: string; }; /** Shown when a prompt is submitted with no backend client wired into the seam. */ export const BACKEND_UNAVAILABLE_MESSAGE = 'Rust backend unavailable'; +/** Shown when provider configuration has not landed in this bootstrap slice. */ +export const NEEDS_CONFIGURATION_MESSAGE = + 'Provider configuration is not available yet. Continue with the provider setup PR.'; + export type QueueItem = { id: number; text: string; @@ -20,20 +28,84 @@ export type QueueItem = { result?: BackendResult; }; -export function queueToBodyEntries(queue: readonly QueueItem[]): BodyEntry[] { +/** Cached body entries for one queue item plus the streaming text they reflect. */ +type ItemEntriesCacheSlot = { streamingText: string | undefined; entries: BodyEntry[] }; + +// Body entries for an unchanged item are stable, so memoize them per item +// identity. `QueueItem` objects are immutable — state/result transitions create a +// new object (see the prompt-queue atoms) and only the active turn's streaming +// text mutates in place — so a cache hit needs just an identity match plus an +// unchanged streaming string. This keeps `BodyEntry` references stable across +// tokens (so downstream row wrapping stays cached) and skips re-sanitizing the +// whole transcript on every delta. Settled/cleared items are GC'd from the +// WeakMap once the queue drops them. +const entriesByItem = new WeakMap(); + +/** + * Maps the prompt queue to transcript body entries. `streamingTextById` carries + * the live assistant text for in-flight turns (see `streamingTextByIdAtom`); an + * entry keyed by an unsettled item's `id` renders the streaming assistant row. + * + * Results are memoized per item (see `entriesByItem`), so an unchanged item + * returns identical `BodyEntry` references and only a mutated (streaming) item is + * rebuilt. + */ +export function queueToBodyEntries( + queue: readonly QueueItem[], + streamingTextById?: ReadonlyMap +): BodyEntry[] { return queue.flatMap((item) => { - const promptText = sanitizeDisplayText(item.text); - const promptEntry: BodyEntry = - item.state === 'queued' - ? { id: `prompt-${item.id}`, kind: BodyEntryKind.Pending, text: promptText } - : { id: `prompt-${item.id}`, kind: BodyEntryKind.User, text: promptText }; - - return item.result === undefined - ? [promptEntry] - : [promptEntry, { id: `result-${item.id}`, kind: item.result.kind, text: item.result.text }]; + const streamingText = streamingTextById?.get(item.id); + const cached = entriesByItem.get(item); + if (cached !== undefined && cached.streamingText === streamingText) { + return cached.entries; + } + + const entries = buildItemEntries(item, streamingText); + entriesByItem.set(item, { streamingText, entries }); + return entries; }); } +function buildItemEntries(item: QueueItem, streamingText: string | undefined): BodyEntry[] { + const promptText = sanitizeDisplayText(item.text); + const promptEntry: BodyEntry = + item.state === 'queued' + ? { id: `prompt-${item.id}`, kind: BodyEntryKind.Pending, text: promptText } + : { id: `prompt-${item.id}`, kind: BodyEntryKind.User, text: promptText }; + + if (item.result !== undefined) { + return [ + promptEntry, + { id: `result-${item.id}`, kind: item.result.kind, text: item.result.text } + ]; + } + + if (streamingText !== undefined) { + return [ + promptEntry, + { + id: `stream-${item.id}`, + kind: BodyEntryKind.Assistant, + text: sanitizeDisplayText(streamingText) + } + ]; + } + + return [promptEntry]; +} + +/** Maps a streamed turn's terminal {@link StreamOutcome} to a transcript result. */ +export function outcomeToResult(outcome: StreamOutcome): BackendResult { + if (outcome.kind === 'completed') { + return { kind: BodyEntryKind.Assistant, text: sanitizeDisplayText(outcome.text) }; + } + if (outcome.kind === 'needsConfiguration') { + return { kind: BodyEntryKind.Error, text: sanitizeDisplayText(NEEDS_CONFIGURATION_MESSAGE) }; + } + return { kind: BodyEntryKind.Error, text: sanitizeDisplayText(outcome.message) }; +} + export function backendErrorMessage(error: unknown): string { if (error instanceof BackendClientError) { return `Rust backend failed: ${error.message}`; diff --git a/tui/src/libs/promptQueue/streamCoalescer.ts b/tui/src/libs/promptQueue/streamCoalescer.ts new file mode 100644 index 00000000..14bb3467 --- /dev/null +++ b/tui/src/libs/promptQueue/streamCoalescer.ts @@ -0,0 +1,63 @@ +/** Batches streamed text deltas and flushes them on a trailing-edge timer. */ +export type DeltaCoalescer = { + /** Buffers `delta` and schedules a flush if none is already pending. */ + push: (delta: string) => void; + /** Emits any buffered text now and clears the pending flush. */ + flush: () => void; + /** Clears the pending flush and discards any buffered text. */ + cancel: () => void; +}; + +/** + * Coalesces streamed text deltas so a consumer re-renders at most once per + * `intervalMs` (a max-fps ceiling) instead of once per token. + * + * `push` appends to a buffer and, when no flush is pending, schedules one + * `intervalMs` later; `emit` then receives the whole batch in a single call. + * Windows with no new deltas emit nothing, so the effective flush rate floats + * down to the delta arrival rate and is capped at `1000 / intervalMs` fps — + * adaptive without tracking anything but the deltas themselves. + * + * Trailing-edge only (no leading flush) and clock-free (pure `setTimeout`), so a + * caller drives it deterministically in tests with fake timers. + */ +export function createDeltaCoalescer( + emit: (batch: string) => void, + intervalMs: number +): DeltaCoalescer { + let buffer = ''; + let timer: ReturnType | undefined; + + const flush = (): void => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + if (buffer.length === 0) { + return; + } + const batch = buffer; + buffer = ''; + emit(batch); + }; + + return { + push(delta: string): void { + if (delta.length === 0) { + return; + } + buffer += delta; + if (timer === undefined) { + timer = setTimeout(flush, intervalMs); + } + }, + flush, + cancel(): void { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + buffer = ''; + } + }; +} diff --git a/tui/src/libs/terminal/__tests__/mouse.test.ts b/tui/src/libs/terminal/__tests__/mouse.test.ts new file mode 100644 index 00000000..8735bfbc --- /dev/null +++ b/tui/src/libs/terminal/__tests__/mouse.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + isMouseInput, + parseMouseClickEvent, + parseMouseWheelEvent, + parseMouseWheelInput +} from '@libs/terminal/mouse.ts'; + +describe('parseMouseWheelEvent', () => { + it('parses wheel-up with its 1-based pointer row', () => { + expect(parseMouseWheelEvent('\u001B[<64;1;1M')).toEqual({ direction: 'up', row: 1 }); + }); + + it('parses wheel-down with the row from the third SGR field', () => { + expect(parseMouseWheelEvent('\u001B[<65;10;7M')).toEqual({ direction: 'down', row: 7 }); + }); + + it('decodes wheel buttons carrying modifier bits as plain up/down', () => { + // 64 + 16 (Ctrl modifier bit) still resolves to wheel-up via the modulo. + expect(parseMouseWheelEvent('\u001B[<80;3;4M')).toEqual({ direction: 'up', row: 4 }); + }); + + it('ignores release events and non-wheel buttons', () => { + expect(parseMouseWheelEvent('\u001B[<64;1;1m')).toBeNull(); // release event + expect(parseMouseWheelEvent('\u001B[<0;1;1M')).toBeNull(); // left-button press + }); + + it('returns null for non-mouse input', () => { + expect(parseMouseWheelEvent('hello')).toBeNull(); + }); +}); + +describe('parseMouseWheelInput (delegates to parseMouseWheelEvent)', () => { + it('returns the direction only', () => { + expect(parseMouseWheelInput('\u001B[<64;1;1M')).toBe('up'); + expect(parseMouseWheelInput('\u001B[<65;1;1M')).toBe('down'); + expect(parseMouseWheelInput('hello')).toBeNull(); + }); +}); + +describe('isMouseInput', () => { + it('matches SGR mouse sequences and rejects plain text', () => { + expect(isMouseInput('\u001B[<64;1;1M')).toBe(true); + expect(isMouseInput('\u001B[<0;5;9m')).toBe(true); + expect(isMouseInput('abc')).toBe(false); + }); +}); + +describe('parseMouseClickEvent', () => { + it('parses a left-button press into its 1-based row/column', () => { + expect(parseMouseClickEvent('\u001B[<0;12;5M')).toEqual({ row: 5, column: 12 }); + }); + + it('ignores release events, wheel events, and other buttons', () => { + expect(parseMouseClickEvent('\u001B[<0;12;5m')).toBeNull(); // release + expect(parseMouseClickEvent('\u001B[<64;1;1M')).toBeNull(); // wheel + expect(parseMouseClickEvent('\u001B[<2;1;1M')).toBeNull(); // right button + }); + + it('returns null for non-mouse input', () => { + expect(parseMouseClickEvent('hello')).toBeNull(); + }); +}); diff --git a/tui/src/libs/terminal/mouse.ts b/tui/src/libs/terminal/mouse.ts index 7121c469..9380c4f0 100644 --- a/tui/src/libs/terminal/mouse.ts +++ b/tui/src/libs/terminal/mouse.ts @@ -3,15 +3,36 @@ export const DISABLE_SGR_MOUSE_TRACKING = '\u001B[?1006l\u001B[?1000l'; type MouseWheelDirection = 'up' | 'down'; -const SGR_MOUSE_INPUT_PATTERN = /^(?:\u001B)?\[<(?\d+);\d+;\d+(?[mM])$/; +/** A parsed SGR mouse-wheel press with its 1-based pointer row. */ +export type MouseWheelEvent = { + direction: MouseWheelDirection; + /** 1-based terminal row (SGR Y coordinate) the pointer was on. */ + row: number; +}; + +/** A parsed SGR left-button press with its 1-based pointer position. */ +export type MouseClickEvent = { + row: number; + column: number; +}; + +// SGR mouse reports are `ESC[\d+);(?\d+);(?\d+)(?[mM])$/; const WHEEL_BUTTON_OFFSET = 64; const WHEEL_BUTTON_COUNT = 4; +const LEFT_BUTTON_CODE = 0; export function isMouseInput(input: string): boolean { return SGR_MOUSE_INPUT_PATTERN.test(input); } -export function parseMouseWheelInput(input: string): MouseWheelDirection | null { +/** + * Parses a wheel-button press into its direction and 1-based pointer row, or + * `null` when `input` is not a wheel press (`M`) event. The row lets a caller + * route the notch to whichever pane the pointer is over. + */ +export function parseMouseWheelEvent(input: string): MouseWheelEvent | null { const match = SGR_MOUSE_INPUT_PATTERN.exec(input); if (match?.groups === undefined || match.groups.eventType !== 'M') { return null; @@ -25,13 +46,36 @@ export function parseMouseWheelInput(input: string): MouseWheelDirection | null // SGR mouse encodes wheel events starting at button 64; modulo strips any // modifier bits while keeping 0/1 as vertical wheel up/down. const wheelButton = (buttonCode - WHEEL_BUTTON_OFFSET) % WHEEL_BUTTON_COUNT; - if (wheelButton === 0) { - return 'up'; + const direction = wheelButton === 0 ? 'up' : wheelButton === 1 ? 'down' : null; + if (direction === null) { + return null; } - if (wheelButton === 1) { - return 'down'; + return { direction, row: Number.parseInt(match.groups.row, 10) }; +} + +/** Back-compat helper: the wheel direction only. Prefer {@link parseMouseWheelEvent}. */ +export function parseMouseWheelInput(input: string): MouseWheelDirection | null { + return parseMouseWheelEvent(input)?.direction ?? null; +} + +/** + * Parses a left-button press (button 0, `M`) into its 1-based row/column, or + * `null` for any other event (release `m`, wheel, other buttons, or non-mouse + * input). Lets a caller move the cursor to the click position. + */ +export function parseMouseClickEvent(input: string): MouseClickEvent | null { + const match = SGR_MOUSE_INPUT_PATTERN.exec(input); + if (match?.groups === undefined || match.groups.eventType !== 'M') { + return null; + } + + if (Number.parseInt(match.groups.buttonCode, 10) !== LEFT_BUTTON_CODE) { + return null; } - return null; + return { + row: Number.parseInt(match.groups.row, 10), + column: Number.parseInt(match.groups.column, 10) + }; } diff --git a/tui/src/libs/tui/__tests__/bodyRows.test.ts b/tui/src/libs/tui/__tests__/bodyRows.test.ts index 4d76de63..84e1b2da 100644 --- a/tui/src/libs/tui/__tests__/bodyRows.test.ts +++ b/tui/src/libs/tui/__tests__/bodyRows.test.ts @@ -46,3 +46,35 @@ describe('resolveBodyRows hard line breaks', () => { expect(texts.every((line) => line.length <= WIDE_COLUMNS)).toBe(true); }); }); + +describe('resolveBodyRows memoization', () => { + it('returns the same row objects for an unchanged entry and width', () => { + const entry = { kind: BodyEntryKind.Assistant, text: 'stable text' }; + + const first = resolveBodyRows([entry], WIDE_COLUMNS, TALL_ROWS); + const second = resolveBodyRows([entry], WIDE_COLUMNS, TALL_ROWS); + + expect(second[0]).toBe(first[0]); + expect(second).toEqual(first); + }); + + it('recomputes fresh rows when the column width changes', () => { + const entry = { kind: BodyEntryKind.Assistant, text: 'stable text' }; + + const wide = resolveBodyRows([entry], WIDE_COLUMNS, TALL_ROWS); + const narrow = resolveBodyRows([entry], 20, TALL_ROWS); + + expect(narrow[0]).not.toBe(wide[0]); + }); + + it('does not reuse rows across distinct entry objects with equal content', () => { + const a = { kind: BodyEntryKind.Assistant, text: 'same' }; + const b = { kind: BodyEntryKind.Assistant, text: 'same' }; + + const rowsA = resolveBodyRows([a], WIDE_COLUMNS, TALL_ROWS); + const rowsB = resolveBodyRows([b], WIDE_COLUMNS, TALL_ROWS); + + expect(rowsB[0]).not.toBe(rowsA[0]); + expect(rowsB).toEqual(rowsA); + }); +}); diff --git a/tui/src/libs/tui/__tests__/layout.test.ts b/tui/src/libs/tui/__tests__/layout.test.ts index f2df7d2e..a038459b 100644 --- a/tui/src/libs/tui/__tests__/layout.test.ts +++ b/tui/src/libs/tui/__tests__/layout.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; import { resolveHomeScreenLayout } from '@libs/tui/layout.ts'; +import { + COMPOSER_BACKGROUND_PADDING_ROWS, + COMPOSER_MAX_HEIGHT_DIVISOR +} from '@constants/ui.ts'; + +const COMPOSER_ERROR_RESERVE_ROWS = 1; describe('resolveHomeScreenLayout with a command menu', () => { it('reflows the body by the menu height when the transcript fills the pane', () => { @@ -15,3 +21,27 @@ describe('resolveHomeScreenLayout with a command menu', () => { expect(layout.bodyRows).toBeGreaterThanOrEqual(1); }); }); + +describe('resolveHomeScreenLayout composer height cap', () => { + it.each([24, 40, 15])('caps the composer box within floor(rows/2) at rows=%i', (rows) => { + const layout = resolveHomeScreenLayout(rows, 1000, 3, 1, 0); + const boxRows = + layout.composerVisibleRows + COMPOSER_BACKGROUND_PADDING_ROWS + COMPOSER_ERROR_RESERVE_ROWS; + + expect(boxRows).toBeLessThanOrEqual(Math.floor(rows / COMPOSER_MAX_HEIGHT_DIVISOR)); + expect(layout.composerVisibleRows).toBeGreaterThanOrEqual(1); + expect(layout.bodyRows).toBeGreaterThanOrEqual(1); + }); + + it('stops composer growth at the cap regardless of transcript length', () => { + const short = resolveHomeScreenLayout(24, 2, 3, 1, 0); + const long = resolveHomeScreenLayout(24, 100_000, 3, 1, 0); + const expected = + Math.floor(24 / COMPOSER_MAX_HEIGHT_DIVISOR) - + COMPOSER_BACKGROUND_PADDING_ROWS - + COMPOSER_ERROR_RESERVE_ROWS; + + expect(long.composerVisibleRows).toBe(short.composerVisibleRows); + expect(long.composerVisibleRows).toBe(expected); + }); +}); diff --git a/tui/src/libs/tui/bodyRows.ts b/tui/src/libs/tui/bodyRows.ts index 477381b8..1a50e257 100644 --- a/tui/src/libs/tui/bodyRows.ts +++ b/tui/src/libs/tui/bodyRows.ts @@ -53,7 +53,45 @@ function toBodyRowsWithEntryGaps(entries: readonly BodyEntry[], columns: number) return entries.flatMap((entry) => toBodyRows(entry, columns)); } +// Wrapping a `BodyEntry` depends only on its immutable kind/text and the column +// width, so memoize the rendered rows per entry identity and width. During +// streaming only the changed entry is a fresh object (new identity), so the rest +// of the transcript hits the cache instead of re-wrapping on every token/render; +// resizes and scrolls reuse it too. Entries are GC'd from the WeakMap once the +// transcript drops them (e.g. on `/clear`). +// NOTE: assumes `theme` colors are static for the process. If runtime theming is +// added, include the active theme in the cache key. +const MAX_CACHED_WIDTHS = 4; +const bodyRowsByEntry = new WeakMap>(); + function toBodyRows(entry: BodyEntry, columns: number): BodyRow[] { + let rowsByWidth = bodyRowsByEntry.get(entry); + if (rowsByWidth === undefined) { + rowsByWidth = new Map(); + bodyRowsByEntry.set(entry, rowsByWidth); + } + + const cached = rowsByWidth.get(columns); + if (cached !== undefined) { + return cached; + } + + // Bound the per-entry width cache so a continuous terminal resize (many + // distinct widths) cannot grow it without limit; the current and scrollbar + // widths always stay resident. + if (rowsByWidth.size >= MAX_CACHED_WIDTHS) { + const oldest = rowsByWidth.keys().next().value; + if (oldest !== undefined) { + rowsByWidth.delete(oldest); + } + } + + const rows = computeBodyRows(entry, columns); + rowsByWidth.set(columns, rows); + return rows; +} + +function computeBodyRows(entry: BodyEntry, columns: number): BodyRow[] { if (entry.kind === BodyEntryKind.User) { return toPromptRows(entry.text, columns); } diff --git a/tui/src/libs/tui/layout.ts b/tui/src/libs/tui/layout.ts index 133fa167..ea827495 100644 --- a/tui/src/libs/tui/layout.ts +++ b/tui/src/libs/tui/layout.ts @@ -1,3 +1,8 @@ +import { + COMPOSER_BACKGROUND_PADDING_ROWS, + COMPOSER_MAX_HEIGHT_DIVISOR +} from '@constants/ui.ts'; + /** * Rows occupied by the always-visible home-screen header (product name + * version). The app only renders at or above `MIN_COLUMNS`, so the header no @@ -8,7 +13,6 @@ export const HEADER_ROWS = 1; export const DEFAULT_COMPOSER_ROWS = 3; export const BODY_CWD_GAP_ROWS = 1; -const COMPOSER_BACKGROUND_PADDING_ROWS = 2; const COMPOSER_ERROR_RESERVE_ROWS = 1; /** @@ -36,9 +40,19 @@ export function resolveHomeScreenLayout( // Fixed rows exclude the composer because it grows with wrapping/validation; // reserving one possible error row keeps the body from collapsing below 1 row. const fixedRows = headerRows + BODY_CWD_GAP_ROWS + resolvedCwdRows + statusRows; + // Cap the composer's visible text rows so the whole box (text + background + // padding + reserved error row) never exceeds `rows / COMPOSER_MAX_HEIGHT_DIVISOR` + // (half the terminal), keeping the transcript visible. The `min` with the + // body-preserving budget only ever shrinks the composer, so the total can + // never over-subscribe the canvas. const maxComposerVisibleRows = Math.max( 1, - rows - fixedRows - COMPOSER_BACKGROUND_PADDING_ROWS - composerErrorReserveRows - minBodyRows + Math.min( + rows - fixedRows - COMPOSER_BACKGROUND_PADDING_ROWS - composerErrorReserveRows - minBodyRows, + Math.floor(rows / COMPOSER_MAX_HEIGHT_DIVISOR) - + COMPOSER_BACKGROUND_PADDING_ROWS - + composerErrorReserveRows + ) ); // The command menu (when open) renders above the composer; its rows come out // of the body budget so the composer and status stay pinned to the bottom and diff --git a/tui/src/state/global/backend.ts b/tui/src/state/global/backend.ts index 3b903298..e3b9c5e8 100644 --- a/tui/src/state/global/backend.ts +++ b/tui/src/state/global/backend.ts @@ -1,5 +1,5 @@ import { atom } from 'jotai'; import type { BackendClient } from '@contracts/backend/index.ts'; -/** Injected backend seam (submitMessage-only) the prompt queue submits through. */ +/** Injected backend seam the prompt queue submits through and reads git status from. */ export const backendClientAtom = atom(undefined); diff --git a/tui/src/state/promptQueue/atoms.ts b/tui/src/state/promptQueue/atoms.ts index 32ace07c..07cd37ad 100644 --- a/tui/src/state/promptQueue/atoms.ts +++ b/tui/src/state/promptQueue/atoms.ts @@ -4,18 +4,21 @@ import { BodyEntryKind } from '@constants/bodyEntry.ts'; import { sanitizeDisplayText } from '@libs/text/sanitizeDisplayText.ts'; import { unknownCommandMessage } from '@libs/commands/unknownCommand.ts'; import { backendClientAtom } from '@state/global/index.ts'; -import { bodyScrollOffsetRowsAtom, submittedPromptEntriesAtom } from '@state/ui/index.ts'; +import { bodyScrollOffsetRowsAtom } from '@state/ui/index.ts'; +import { promptQueueAtom, streamingTextByIdAtom } from '@state/promptQueue/store.ts'; +import { refreshGitStatusAtom } from '@state/ui/index.ts'; +import { createDeltaCoalescer } from '@libs/promptQueue/streamCoalescer.ts'; +import { STREAM_RENDER_FLUSH_MS } from '@constants/backend.ts'; import { BACKEND_UNAVAILABLE_MESSAGE, backendErrorMessage, - queueToBodyEntries + outcomeToResult } from '@libs/promptQueue/promptQueue.ts'; import type { BackendResult, QueueItem } from '@libs/promptQueue/promptQueue.ts'; -let nextQueueItemId = 0; +export { promptQueueAtom, streamingTextByIdAtom }; -/** Ordered record of submitted prompts and their backend outcomes. */ -export const promptQueueAtom = atom([]); +let nextQueueItemId = 0; const drainingAtom = atom(false); @@ -37,15 +40,14 @@ export const enqueuePromptAtom = atom(null, async (get, set, rawText: string) => set(promptQueueAtom, (queue) => [...queue, item]); set(bodyScrollOffsetRowsAtom, 0); - syncBodyEntries(get, set); await drainQueue(get, set); }); /** Clears all transcript entries (prompts and results) and resets scroll. */ -export const clearTranscriptAtom = atom(null, (get, set) => { +export const clearTranscriptAtom = atom(null, (_get, set) => { set(promptQueueAtom, []); + set(streamingTextByIdAtom, new Map()); set(bodyScrollOffsetRowsAtom, 0); - syncBodyEntries(get, set); }); /** @@ -54,7 +56,7 @@ export const clearTranscriptAtom = atom(null, (get, set) => { * the unmatched command. The item is pre-settled so the backend queue never * sends it, mirroring how a real prompt and its result appear in the body. */ -export const appendUnknownCommandAtom = atom(null, (get, set, rawText: string) => { +export const appendUnknownCommandAtom = atom(null, (_get, set, rawText: string) => { const item: QueueItem = { id: nextQueueItemId++, text: rawText, @@ -64,7 +66,6 @@ export const appendUnknownCommandAtom = atom(null, (get, set, rawText: string) = set(promptQueueAtom, (queue) => [...queue, item]); set(bodyScrollOffsetRowsAtom, 0); - syncBodyEntries(get, set); }); async function drainQueue(get: Getter, set: Setter): Promise { @@ -76,8 +77,11 @@ async function drainQueue(get: Getter, set: Setter): Promise { try { let active = findActive(get); while (active !== undefined) { - const result = await runBackendRequest(get, active.text); - settleActive(get, set, active.id, result); + const result = await streamActive(get, set, active.id, active.text); + settleActive(set, active.id, result); + // A completed turn may have changed the working tree; refresh the git label + // (fire-and-forget so it never delays draining the next queued prompt). + void set(refreshGitStatusAtom); active = findActive(get); } } finally { @@ -85,24 +89,70 @@ async function drainQueue(get: Getter, set: Setter): Promise { } } -async function runBackendRequest(get: Getter, text: string): Promise { +/** + * Streams one prompt to the backend, rendering assistant deltas live into the + * active item, and resolves the terminal transcript result. + * + * The assistant marker appears immediately (empty streaming text), then token + * deltas are coalesced (see `createDeltaCoalescer`) and flushed at most ~15fps + * into the live streaming text; the derived body sticks to the bottom so new + * output stays visible. The final result is rendered by `settleActive`, so the + * coalescer's trailing sub-frame buffer is safely discarded on completion. + * Provider errors and the no-key path settle as themed body entries rather than + * throwing. + */ +async function streamActive( + get: Getter, + set: Setter, + id: number, + text: string +): Promise { const backendClient = get(backendClientAtom); if (backendClient === undefined) { return { kind: BodyEntryKind.Error, text: sanitizeDisplayText(BACKEND_UNAVAILABLE_MESSAGE) }; } + updateStreamingText(set, id, () => ''); + + const coalescer = createDeltaCoalescer((batch) => { + updateStreamingText(set, id, (current) => current + batch); + set(bodyScrollOffsetRowsAtom, 0); + }, STREAM_RENDER_FLUSH_MS); + try { - const ack = await backendClient.submitMessage({ text }); - return { - kind: BodyEntryKind.Success, - text: sanitizeDisplayText(`Rust backend ACK - received: ${ack.receivedText}`) - }; + const outcome = await backendClient.submitStreaming( + { text }, + { onDelta: (delta) => coalescer.push(delta) } + ); + return outcomeToResult(outcome); } catch (error) { return { kind: BodyEntryKind.Error, text: sanitizeDisplayText(backendErrorMessage(error)) }; + } finally { + coalescer.cancel(); } } -function settleActive(get: Getter, set: Setter, id: number, result: BackendResult): void { +/** + * Appends to the active item's live streaming text in O(1). + * + * The text lives in `streamingTextByIdAtom` (at most one entry) rather than in + * the queue array, so a token delta rewrites only that single map entry instead + * of cloning the whole queue. `submittedPromptEntriesAtom` derives its body rows + * from this map, so there is no manual re-sync. + */ +function updateStreamingText( + set: Setter, + id: number, + update: (current: string) => string +): void { + set(streamingTextByIdAtom, (previous) => { + const next = new Map(previous); + next.set(id, update(previous.get(id) ?? '')); + return next; + }); +} + +function settleActive(set: Setter, id: number, result: BackendResult): void { set(promptQueueAtom, (queue) => { let promoted = false; return queue.map((item) => { @@ -116,13 +166,21 @@ function settleActive(get: Getter, set: Setter, id: number, result: BackendResul return item; }); }); - syncBodyEntries(get, set); + discardStreamingText(set, id); } -function findActive(get: Getter): QueueItem | undefined { - return get(promptQueueAtom).find((item) => item.state === 'active'); +/** Drops a settled item's streaming buffer so the map holds at most one entry. */ +function discardStreamingText(set: Setter, id: number): void { + set(streamingTextByIdAtom, (previous) => { + if (!previous.has(id)) { + return previous; + } + const next = new Map(previous); + next.delete(id); + return next; + }); } -function syncBodyEntries(get: Getter, set: Setter): void { - set(submittedPromptEntriesAtom, queueToBodyEntries(get(promptQueueAtom))); +function findActive(get: Getter): QueueItem | undefined { + return get(promptQueueAtom).find((item) => item.state === 'active'); } diff --git a/tui/src/state/promptQueue/store.ts b/tui/src/state/promptQueue/store.ts new file mode 100644 index 00000000..f1174fc2 --- /dev/null +++ b/tui/src/state/promptQueue/store.ts @@ -0,0 +1,17 @@ +import { atom } from 'jotai'; +import type { QueueItem } from '@libs/promptQueue/promptQueue.ts'; + +/** Ordered record of submitted prompts and their backend outcomes. */ +export const promptQueueAtom = atom([]); + +/** + * Live assistant text for in-flight turns, keyed by {@link QueueItem} `id`. + * + * Streaming text mutates on every token delta, so it is held here instead of + * inside {@link promptQueueAtom}: updating one entry is O(1), whereas rewriting + * it into the queue array would clone the whole (session-long, growing) queue on + * every delta. The queue drains sequentially, so only the single active turn + * streams at a time and its entry is deleted once the turn settles — this map + * therefore holds at most one entry, keeping each copy-on-write O(1). + */ +export const streamingTextByIdAtom = atom>(new Map()); diff --git a/tui/src/state/ui/__tests__/composerScroll.test.ts b/tui/src/state/ui/__tests__/composerScroll.test.ts new file mode 100644 index 00000000..d93f0a87 --- /dev/null +++ b/tui/src/state/ui/__tests__/composerScroll.test.ts @@ -0,0 +1,65 @@ +import { createStore } from 'jotai'; +import { describe, expect, it } from 'vitest'; +import { columnsTestOverrideAtom, rowsTestOverrideAtom } from '@state/ui/dimensions.ts'; +import { composerScrollOffsetRowsAtom, composerStateAtom } from '@state/ui/composer/index.ts'; +import { composerCanScrollAtom, scrollComposerByRowsAtom, scrollComposerCursorIntoViewAtom } from '@state/ui/index.ts'; + +type Store = ReturnType; + +const overflowingText = Array.from({ length: 20 }, (_, index) => `line ${index}`).join('\n'); + +const seed = (store: Store, text: string): void => { + store.set(columnsTestOverrideAtom, 60); + store.set(rowsTestOverrideAtom, 24); + store.set(composerStateAtom, { text, cursorIndex: text.length, validationError: null }); +}; + +describe('composer scroll atoms', () => { + it('reports canScroll true when the prompt overflows the cap', () => { + const store = createStore(); + seed(store, overflowingText); + expect(store.get(composerCanScrollAtom)).toBe(true); + }); + + it('reports canScroll false for a short prompt', () => { + const store = createStore(); + seed(store, 'short'); + expect(store.get(composerCanScrollAtom)).toBe(false); + }); + + it('clamps the scroll offset within the resolver bounds', () => { + const store = createStore(); + seed(store, overflowingText); + + store.set(scrollComposerByRowsAtom, 5); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(5); + + // Wheel far past the top: clamps up to maxOffset. + store.set(scrollComposerByRowsAtom, 999); + expect(store.get(composerScrollOffsetRowsAtom)).toBeGreaterThan(5); + + // Wheel far past the bottom: clamps down to minOffset (0 with cursor at end). + store.set(scrollComposerByRowsAtom, -999); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(0); + }); +}); + +describe('scrollComposerCursorIntoViewAtom', () => { + it('snaps the caret back into view when it is scrolled off-window', () => { + const store = createStore(); + seed(store, overflowingText); + // Scroll far toward the top so the caret (at the end) falls below the window. + store.set(scrollComposerByRowsAtom, 999); + expect(store.get(composerScrollOffsetRowsAtom)).toBeGreaterThan(0); + + store.set(scrollComposerCursorIntoViewAtom); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(0); // caret pinned to the bottom edge + }); + + it('leaves the offset untouched when the caret is already visible', () => { + const store = createStore(); + seed(store, overflowingText); // caret at end, offset 0 -> visible at the bottom + store.set(scrollComposerCursorIntoViewAtom); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(0); + }); +}); diff --git a/tui/src/state/ui/__tests__/gitStatus.test.ts b/tui/src/state/ui/__tests__/gitStatus.test.ts new file mode 100644 index 00000000..3c3e5e50 --- /dev/null +++ b/tui/src/state/ui/__tests__/gitStatus.test.ts @@ -0,0 +1,53 @@ +import { createStore } from 'jotai'; +import { describe, expect, it, vi } from 'vitest'; +import type { BackendClient } from '@contracts/backend/index.ts'; +import { backendClientAtom } from '@state/global/index.ts'; +import { gitStatusLabelAtom, refreshGitStatusAtom } from '@state/ui/gitStatus.ts'; + +function clientWithGitStatus(gitStatus: BackendClient['gitStatus']): BackendClient { + return { submitStreaming: vi.fn(), gitStatus }; +} + +describe('refreshGitStatusAtom', () => { + it('stores the label the backend returns', async () => { + const store = createStore(); + store.set(backendClientAtom, clientWithGitStatus(async () => '⎇ main*')); + + await store.set(refreshGitStatusAtom); + + expect(store.get(gitStatusLabelAtom)).toBe('⎇ main*'); + }); + + it('clears the label to undefined when the workspace is not a git repository', async () => { + const store = createStore(); + store.set(gitStatusLabelAtom, '⎇ main'); + store.set(backendClientAtom, clientWithGitStatus(async () => null)); + + await store.set(refreshGitStatusAtom); + + expect(store.get(gitStatusLabelAtom)).toBeUndefined(); + }); + + it('keeps the last known label when the request fails', async () => { + const store = createStore(); + store.set(gitStatusLabelAtom, '⎇ main'); + store.set( + backendClientAtom, + clientWithGitStatus(async () => { + throw new Error('transport died'); + }) + ); + + await store.set(refreshGitStatusAtom); + + expect(store.get(gitStatusLabelAtom)).toBe('⎇ main'); + }); + + it('is a no-op when no backend client is wired', async () => { + const store = createStore(); + + await store.set(refreshGitStatusAtom); + + expect(store.get(gitStatusLabelAtom)).toBeUndefined(); + }); +}); diff --git a/tui/src/state/ui/atoms.ts b/tui/src/state/ui/atoms.ts index 7823f928..55c5a764 100644 --- a/tui/src/state/ui/atoms.ts +++ b/tui/src/state/ui/atoms.ts @@ -1,6 +1,7 @@ import { atom } from 'jotai'; import { countBodyRows, DEFAULT_BODY_ENTRIES } from '@libs/tui/bodyRows.ts'; -import type { BodyEntry } from '@libs/tui/bodyRows.ts'; +import { queueToBodyEntries } from '@libs/promptQueue/promptQueue.ts'; +import { promptQueueAtom, streamingTextByIdAtom } from '@state/promptQueue/store.ts'; import { countCwdRows } from '@libs/tui/cwdLine.ts'; import { BODY_CWD_GAP_ROWS, @@ -14,10 +15,19 @@ import { bodyEntriesAtom } from '@state/ui/body.ts'; import { columnsAtom, rowsAtom } from '@state/ui/dimensions.ts'; import { gitStatusLabelAtom } from '@state/ui/gitStatus.ts'; import { commandMenuDesiredRowsAtom, commandMenuOpenAtom } from '@state/ui/commands/index.ts'; +import { PROMPT_PREFIX } from '@constants/ui.ts'; +import { + resolveComposerWindow, + resolveScrollIntoViewOffset +} from '@libs/composer/composerWindow.ts'; +import { composerScrollOffsetRowsAtom, composerStateAtom } from '@state/ui/composer/index.ts'; export const bodyScrollOffsetRowsAtom = atom(0); export const composerRowsAtom = atom(DEFAULT_COMPOSER_ROWS); -export const submittedPromptEntriesAtom = atom([]); +/** Transcript body rows derived from the prompt queue and live streaming text. */ +export const submittedPromptEntriesAtom = atom((get) => + queueToBodyEntries(get(promptQueueAtom), get(streamingTextByIdAtom)) +); /** * Rows the cwd line occupies in the layout. It collapses to `0` while the command @@ -119,3 +129,54 @@ export const scrollBodyByRowsAtom = atom(null, (get, set, deltaRows: number) => clamp(current + deltaRows, 0, maxBodyScrollOffsetRows) ); }); + +/** + * The composer's current visible window, derived from the live composer text, + * the shared input width (`columns − PROMPT_PREFIX.length`, matching the render), + * the layout's visible-line cap, and the scroll offset. Its `canScroll` and + * `minOffset`/`maxOffset` drive the wheel router and the scroll clamp. + */ +const composerWindowAtom = atom((get) => { + const inputColumns = Math.max(1, get(columnsAtom) - PROMPT_PREFIX.length); + const { text, cursorIndex } = get(composerStateAtom); + return resolveComposerWindow({ + text, + columns: inputColumns, + maxVisibleLines: get(layoutAtom).composerVisibleRows, + cursorIndex, + offset: get(composerScrollOffsetRowsAtom) + }); +}); + +/** Whether the composer overflows its cap — drives wheel fall-through to the body. */ +export const composerCanScrollAtom = atom((get) => get(composerWindowAtom).canScroll); + +export const scrollComposerByRowsAtom = atom(null, (get, set, deltaRows: number) => { + const { minOffset, maxOffset } = get(composerWindowAtom); + set(composerScrollOffsetRowsAtom, (current) => + clamp(current + deltaRows, minOffset, maxOffset) + ); +}); + +/** + * Adjusts the composer scroll offset so the caret stays visible after a text + * edit or cursor move, without snapping the view to the bottom: a no-op while + * the caret is already within the scrolled window, otherwise a minimal scroll to + * the nearest edge. The composer dispatches this whenever the cursor index + * changes (never on wheel scroll, which does not move the cursor), so typing + * after a click keeps the current view. + */ +export const scrollComposerCursorIntoViewAtom = atom(null, (get, set) => { + const inputColumns = Math.max(1, get(columnsAtom) - PROMPT_PREFIX.length); + const { text, cursorIndex } = get(composerStateAtom); + set( + composerScrollOffsetRowsAtom, + resolveScrollIntoViewOffset({ + text, + columns: inputColumns, + maxVisibleLines: get(layoutAtom).composerVisibleRows, + cursorIndex, + offset: get(composerScrollOffsetRowsAtom) + }) + ); +}); diff --git a/tui/src/state/ui/composer/__tests__/atoms.test.ts b/tui/src/state/ui/composer/__tests__/atoms.test.ts index 25932bcc..6ad91dbf 100644 --- a/tui/src/state/ui/composer/__tests__/atoms.test.ts +++ b/tui/src/state/ui/composer/__tests__/atoms.test.ts @@ -1,11 +1,17 @@ import { createStore } from 'jotai'; import { describe, expect, it } from 'vitest'; import { + caretSuppressedWhileScrollingAtom, + clearComposerAtom, + composerScrollOffsetRowsAtom, composerStateAtom, deleteComposerBackwardAtom, insertComposerTextAtom, moveComposerCursorBackwardAtom, - moveComposerCursorForwardAtom + moveComposerCursorDownAtom, + moveComposerCursorForwardAtom, + moveComposerCursorUpAtom, + setComposerCursorWithOffsetAtom } from '@state/ui/composer/index.ts'; describe('composerAtoms', () => { @@ -39,3 +45,77 @@ describe('composerAtoms', () => { expect(store.get(composerStateAtom).text).toBe('abXcY'); }); }); + +describe('composer scroll offset preservation', () => { + it('preserves the scroll offset across text edits and cursor moves', () => { + const store = createStore(); + store.set(composerStateAtom, { text: 'aaa\nbbb\nccc', cursorIndex: 5, validationError: null }); + const actions = [ + () => store.set(insertComposerTextAtom, { text: 'x' }), + () => store.set(deleteComposerBackwardAtom, {}), + () => store.set(moveComposerCursorBackwardAtom), + () => store.set(moveComposerCursorForwardAtom) + ]; + + for (const action of actions) { + store.set(composerScrollOffsetRowsAtom, 4); + action(); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(4); + } + }); + + it('resets the scroll offset when the composer is cleared', () => { + const store = createStore(); + store.set(composerStateAtom, { text: 'aaa\nbbb', cursorIndex: 3, validationError: null }); + store.set(composerScrollOffsetRowsAtom, 3); + store.set(clearComposerAtom); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(0); + }); +}); + +describe('composer vertical cursor movement', () => { + it('moves the cursor up and down between visual lines and preserves the offset', () => { + const store = createStore(); + store.set(composerStateAtom, { text: 'aaa\nbbb\nccc', cursorIndex: 11, validationError: null }); + store.set(composerScrollOffsetRowsAtom, 3); + + store.set(moveComposerCursorUpAtom, { columns: 40 }); + expect(store.get(composerStateAtom).cursorIndex).toBe(7); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(3); + + store.set(moveComposerCursorDownAtom, { columns: 40 }); + expect(store.get(composerStateAtom).cursorIndex).toBe(11); + }); + + it('is a no-op at the first visual line and preserves the offset', () => { + const store = createStore(); + store.set(composerStateAtom, { text: 'aaa\nbbb', cursorIndex: 1, validationError: null }); + store.set(composerScrollOffsetRowsAtom, 2); + + store.set(moveComposerCursorUpAtom, { columns: 40 }); + expect(store.get(composerStateAtom).cursorIndex).toBe(1); // unchanged + expect(store.get(composerScrollOffsetRowsAtom)).toBe(2); // preserved + }); +}); + +describe('composer click-to-position', () => { + it('sets the clamped cursor index and the given scroll offset (no snap-back)', () => { + const store = createStore(); + store.set(composerStateAtom, { text: 'hello', cursorIndex: 5, validationError: null }); + store.set(composerScrollOffsetRowsAtom, 2); + + store.set(setComposerCursorWithOffsetAtom, { index: 2, offset: -1 }); + expect(store.get(composerStateAtom).cursorIndex).toBe(2); + expect(store.get(composerScrollOffsetRowsAtom)).toBe(-1); + + store.set(setComposerCursorWithOffsetAtom, { index: 999, offset: 0 }); // clamps to text length + expect(store.get(composerStateAtom).cursorIndex).toBe(5); + }); +}); + +describe('caret scroll suppression', () => { + it('defaults to not suppressed', () => { + const store = createStore(); + expect(store.get(caretSuppressedWhileScrollingAtom)).toBe(false); + }); +}); diff --git a/tui/src/state/ui/composer/atoms.ts b/tui/src/state/ui/composer/atoms.ts index 1cc6e109..5171ae2e 100644 --- a/tui/src/state/ui/composer/atoms.ts +++ b/tui/src/state/ui/composer/atoms.ts @@ -1,6 +1,7 @@ import { atom } from 'jotai'; import { clamp } from '@libs/math/clamp.ts'; import { overLimitMessage, PROMPT_MAX_BYTES } from '@libs/composer/promptText.ts'; +import { resolveVerticalCursorIndex } from '@libs/composer/composerWindow.ts'; export type ComposerState = { text: string; @@ -25,6 +26,24 @@ export const initialComposerState: ComposerState = { export const composerStateAtom = atom(initialComposerState); +/** + * Signed rows the composer view is scrolled away from its cursor-follow baseline + * (+ up / - down). Text/cursor mutations below do NOT reset it; the composer + * dispatches `scrollComposerCursorIntoViewAtom` on cursor changes to keep the + * caret visible with minimal scrolling, so typing after a click preserves the + * current view instead of snapping to the bottom. `clearComposerAtom` resets it + * since the text (and any scroll) is gone. + */ +export const composerScrollOffsetRowsAtom = atom(0); + +/** + * True while the user is actively scrolling (wheel / page keys). The composer + * hides its caret while this holds and re-shows it once scrolling settles, so + * the terminal cursor's blink is not reset on every scrolled frame. Driven by + * `useCaretScrollSuppression`. + */ +export const caretSuppressedWhileScrollingAtom = atom(false); + export const insertComposerTextAtom = atom( null, (_get, set, { maxBytes = PROMPT_MAX_BYTES, text: insertedText }: InsertTextOptions) => { @@ -97,7 +116,39 @@ export const moveComposerCursorForwardAtom = atom(null, (_get, set) => { }); }); +export const moveComposerCursorUpAtom = atom(null, (_get, set, { columns }: { columns: number }) => { + set(composerStateAtom, (state) => { + const target = resolveVerticalCursorIndex(state.text, columns, state.cursorIndex, 'up'); + return target === null ? state : { ...state, cursorIndex: target }; + }); +}); + +export const moveComposerCursorDownAtom = atom(null, (_get, set, { columns }: { columns: number }) => { + set(composerStateAtom, (state) => { + const target = resolveVerticalCursorIndex(state.text, columns, state.cursorIndex, 'down'); + return target === null ? state : { ...state, cursorIndex: target }; + }); +}); + +/** + * Places the caret at `index` while setting the scroll `offset` explicitly. The + * click path passes the offset that keeps the visible window fixed (see + * `resolveClickResult`), so clicking to reposition the caret does not scroll the + * composer. + */ +export const setComposerCursorWithOffsetAtom = atom( + null, + (_get, set, { index, offset }: { index: number; offset: number }) => { + set(composerScrollOffsetRowsAtom, offset); + set(composerStateAtom, (state) => ({ + ...state, + cursorIndex: clampCursorIndex(state.text, index) + })); + } +); + export const clearComposerAtom = atom(null, (_get, set) => { + set(composerScrollOffsetRowsAtom, 0); set(composerStateAtom, (state) => { if (state.text.length === 0 && state.validationError === null) { return state; @@ -107,6 +158,9 @@ export const clearComposerAtom = atom(null, (_get, set) => { }); }); +// Intentionally does NOT reset composerScrollOffsetRowsAtom: its only caller +// re-sets an already-set over-limit message (a guarded no-op). A future caller +// that sets/clears the error from a non-insert path while scrolled should reset. export const setComposerValidationErrorAtom = atom(null, (_get, set, message: string | null) => { set(composerStateAtom, (state) => { if (state.validationError === message) { diff --git a/tui/src/state/ui/gitStatus.ts b/tui/src/state/ui/gitStatus.ts index 63c418ed..ca2fb15c 100644 --- a/tui/src/state/ui/gitStatus.ts +++ b/tui/src/state/ui/gitStatus.ts @@ -1,14 +1,27 @@ import { atom } from 'jotai'; -import { readGitStatusLabel } from '@libs/git/gitStatus.ts'; -import { workspaceCwdAtom } from '@state/global/workspace.ts'; +import { backendClientAtom } from '@state/global/backend.ts'; -// Test-only seam to pin a deterministic label; only read under `__TEST__` (see -// `src/globals.d.ts`). The `prod` build folds `__TEST__` to false and DCE's the -// read and — via the `@__PURE__` annotation — this declaration; production reads -// real git status. -export const gitStatusLabelTestOverrideAtom = /* @__PURE__ */ atom(undefined); +/** The latest workspace git status label, or `undefined` before the first fetch. */ +export const gitStatusLabelAtom = atom(undefined); -export const gitStatusLabelAtom = atom((get) => { - const override = __TEST__ ? get(gitStatusLabelTestOverrideAtom) : undefined; - return override ?? readGitStatusLabel(get(workspaceCwdAtom)); +/** + * Refreshes {@link gitStatusLabelAtom} from the backend's `kqode.git.status`. + * + * Best-effort: with no backend client wired, or when the request fails, the last + * known label is kept rather than blanked. The TUI triggers this on startup and + * after each turn (the agent may have changed the working tree). The backend + * runs `git` and formats the label; this only stores the returned string. + */ +export const refreshGitStatusAtom = atom(null, async (get, set) => { + const client = get(backendClientAtom); + if (client === undefined) { + return; + } + + try { + const label = await client.gitStatus(); + set(gitStatusLabelAtom, label ?? undefined); + } catch { + // Keep the last known label; a transient failure should not blank the cwd row. + } }); diff --git a/tui/vitest.config.ts b/tui/vitest.config.ts index ccddc6ec..69462279 100644 --- a/tui/vitest.config.ts +++ b/tui/vitest.config.ts @@ -26,6 +26,12 @@ export default defineConfig({ } }, test: { - environment: 'node' + environment: 'node', + // Disable backend debug logging for the integration tests that spawn the + // real Rust backend, so they never write under the real `~/.kqode/logs` + // (the dev build defaults it on). buildHardenedEnv allowlists KQODE_DEBUG. + env: { + KQODE_DEBUG: '0' + } } }); From a9a5243f706dd67758c39e550c1a1dd668972ffc Mon Sep 17 00:00:00 2001 From: Kefei Qian Date: Tue, 14 Jul 2026 16:37:07 +0800 Subject: [PATCH 02/52] fix(review): harden bootstrap streaming and git status Bound git-status and streaming failure paths, keep best-effort git refreshes from killing the session, reset caret and mouse terminal modes on teardown, and add focused regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c127c9c-ac00-4a90-be0f-4357720a6a8d --- src/git.rs | 45 ++++++++++++++++--- .../client/__tests__/backendClient.test.ts | 34 ++++++++++++++ tui/src/backend/client/backendClient.ts | 38 +++++----------- tui/src/backend/client/backendLifecycle.ts | 16 +++++++ .../backend/client/messageConnectionClient.ts | 38 +++++++++++++--- .../__tests__/messageProtocol.test.ts | 36 +++++++++++++++ tui/src/bootstrap.ts | 2 + .../useCaretScrollSuppression.test.tsx | 31 +++++++++++++ .../HomeScreen/useCaretScrollSuppression.ts | 3 +- tui/src/constants/backend.ts | 3 ++ tui/src/libs/tui/bodyRows.ts | 23 +--------- tui/src/libs/tui/wrapBodyText.ts | 21 +++++++++ .../state/promptQueue/__tests__/atoms.test.ts | 43 ++++++++++++++++++ tui/src/state/promptQueue/atoms.ts | 4 +- 14 files changed, 272 insertions(+), 65 deletions(-) create mode 100644 tui/src/backend/client/backendLifecycle.ts create mode 100644 tui/src/components/HomeScreen/__tests__/useCaretScrollSuppression.test.tsx create mode 100644 tui/src/libs/tui/wrapBodyText.ts diff --git a/src/git.rs b/src/git.rs index c696c6b0..6874b651 100644 --- a/src/git.rs +++ b/src/git.rs @@ -6,7 +6,12 @@ //! formatting live here in the core runtime so the headless CLI and the TUI show //! the same string; the TUI only renders whatever label this returns. -use std::process::{Command, Stdio}; +use std::{ + io::Read, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; /// Branch glyph prefixing every status label. const GIT_BRANCH_ICON: &str = "⎇"; @@ -24,6 +29,10 @@ const STATUS_UPSTREAM_SEPARATOR: &str = "..."; const NO_COMMITS_BRANCH_PREFIX: &str = "No commits yet on "; /// Header text emitted for a detached HEAD. const DETACHED_HEAD_STATUS: &str = "HEAD (no branch)"; +/// Ceiling for the `git status` call before it is treated as unavailable. +const GIT_STATUS_TIMEOUT: Duration = Duration::from_secs(2); +/// Poll interval while waiting for a bounded `git status` child. +const GIT_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(10); /// Parsed porcelain status of the workspace worktree. #[derive(Debug, Eq, PartialEq)] struct GitStatus { @@ -38,16 +47,38 @@ struct GitStatus { /// off the backend's request loop (e.g. on a thread). #[must_use] pub fn status_label() -> Option { - let output = Command::new("git") + let mut child = Command::new("git") .args(["status", "--porcelain=v1", "--branch"]) .stdin(Stdio::null()) - .output() + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() .ok()?; - if !output.status.success() { - return None; - } - parse_status_label(&String::from_utf8_lossy(&output.stdout)) + let deadline = Instant::now() + GIT_STATUS_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + let mut stdout = String::new(); + child.stdout.take()?.read_to_string(&mut stdout).ok()?; + return parse_status_label(&stdout); + } + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(None) => thread::sleep(GIT_STATUS_POLL_INTERVAL), + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } } /// Formats the porcelain `git status --branch` output into a display label, or diff --git a/tui/src/backend/client/__tests__/backendClient.test.ts b/tui/src/backend/client/__tests__/backendClient.test.ts index 096d9d71..1fccb7d3 100644 --- a/tui/src/backend/client/__tests__/backendClient.test.ts +++ b/tui/src/backend/client/__tests__/backendClient.test.ts @@ -14,6 +14,7 @@ import { BackendClientError, BackendErrorKind } from '@contracts/backend/index.t import { type LaunchedBackend } from '@backend/process/backendProcess.ts'; import { SUBMIT_STATUS_STREAMING } from '@contracts/backend/index.ts'; import { + gitStatusRequest, messageSubmitRequest, backendReadyNotification, tokenDeltaNotification, @@ -33,6 +34,7 @@ type FakeBackend = { launched: LaunchedBackend; disposed: () => boolean; emitExit: () => void; + closeTransport: () => void; }; let openServers: MessageConnection[] = []; @@ -90,6 +92,10 @@ function makeFakeBackend( for (const listener of exitListeners) { listener({ code: 1, signal: null }); } + }, + closeTransport: () => { + backendStdout.end(); + backendStdin.end(); } }; } @@ -145,6 +151,19 @@ describe('createBackendClient (fake backend)', () => { client.dispose(); }); + it('rejects with a transport error when the backend dies before readiness', async () => { + const fake = makeFakeBackend(ack, { signalReady: false }); + const client = createBackendClient({ launch: async () => fake.launched }); + + const start = client.ensureStarted(); + fake.closeTransport(); + + await expect(start).rejects.toMatchObject({ kind: BackendErrorKind.Transport }); + expect(client.getState()).toBe(BackendLifecycleState.Dead); + expect(fake.disposed()).toBe(true); + client.dispose(); + }); + it('keeps the backend alive after a recoverable JSON-RPC method error', async () => { const fake = makeFakeBackend((server) => server.onRequest(messageSubmitRequest, () => { @@ -195,6 +214,21 @@ describe('createBackendClient (fake backend)', () => { client.dispose(); }); + it('does not mark the shared backend dead when best-effort git status times out', async () => { + const fake = makeFakeBackend((server) => { + ack(server); + server.onRequest(gitStatusRequest, () => new Promise(() => undefined)); + }); + const client = createBackendClient({ launch: async () => fake.launched, requestTimeoutMs: 50 }); + + await expect(client.gitStatus()).rejects.toMatchObject({ kind: BackendErrorKind.Timeout }); + expect(client.getState()).toBe(BackendLifecycleState.Ready); + + const result = await client.submitStreaming({ text: 'still alive' }, { onDelta: () => {} }); + expect(result).toEqual({ kind: 'completed', text: 'still alive', finishReason: 'stop' }); + client.dispose(); + }); + it('marks the client dead when the backend process exits', async () => { const fake = makeFakeBackend(ack); const client = createBackendClient({ launch: async () => fake.launched }); diff --git a/tui/src/backend/client/backendClient.ts b/tui/src/backend/client/backendClient.ts index 2b3b8846..0f4eb138 100644 --- a/tui/src/backend/client/backendClient.ts +++ b/tui/src/backend/client/backendClient.ts @@ -4,6 +4,8 @@ import type { BackendClient } from '@contracts/backend/index.ts'; import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STARTUP_TIMEOUT_MS } from '@constants/backend.ts'; import type { LaunchedBackend } from '@backend/process/backendProcess.ts'; import { createMessageConnectionClient } from '@backend/client/messageConnectionClient.ts'; +import { BackendLifecycleState } from '@backend/client/backendLifecycle.ts'; +import type { BackendLifecycleState as BackendLifecycleStateValue } from '@backend/client/backendLifecycle.ts'; import { openReadyConnection } from '@backend/client/backendReadiness.ts'; import { isFatalBackendError, @@ -15,26 +17,11 @@ import type { StreamSubmitParams } from '@contracts/backend/index.ts'; -/** Lifecycle of the TUI-owned backend connection. */ -export const BackendLifecycleState = { - /** No backend has been launched yet. */ - Idle: 'idle', - /** A backend launch/connect is in flight. */ - Starting: 'starting', - /** The backend is connected and accepting requests. */ - Ready: 'ready', - /** The TUI is disposing the backend on purpose. */ - Closing: 'closing', - /** The backend exited, crashed, or a fatal transport error occurred. */ - Dead: 'dead' -} as const; - -export type BackendLifecycleState = - (typeof BackendLifecycleState)[keyof typeof BackendLifecycleState]; +export { BackendLifecycleState }; /** Lifecycle handle over a {@link BackendClient} that owns one child backend at a time. */ export type BackendClientHandle = BackendClient & { - getState(): BackendLifecycleState; + getState(): BackendLifecycleStateValue; ensureStarted(): Promise; dispose(): void; }; @@ -44,6 +31,7 @@ export type BackendClientOptions = { /** Produces a freshly launched backend process; source/packaged factories inject this. */ launch: () => Promise; requestTimeoutMs?: number; + streamIdleTimeoutMs?: number; /** Ceiling for the launched backend to signal JSON-RPC readiness before it is torn down. */ startupTimeoutMs?: number; }; @@ -69,10 +57,11 @@ type BackendSession = { */ export function createBackendClient(options: BackendClientOptions): BackendClientHandle { const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const streamIdleTimeoutMs = options.streamIdleTimeoutMs; const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; const { launch } = options; - let state: BackendLifecycleState = BackendLifecycleState.Idle; + let state: BackendLifecycleStateValue = BackendLifecycleState.Idle; let session: BackendSession | null = null; let starting: Promise | null = null; let disposed = false; @@ -86,7 +75,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien 'backend launch was aborted before it became ready' ); - const teardown = (nextState: BackendLifecycleState): void => { + const teardown = (nextState: BackendLifecycleStateValue): void => { const current = session; session = null; state = nextState; @@ -145,7 +134,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien const opened: BackendSession = { backend, connection, - client: createMessageConnectionClient(connection, { requestTimeoutMs }) + client: createMessageConnectionClient(connection, { requestTimeoutMs, streamIdleTimeoutMs }) }; session = opened; state = BackendLifecycleState.Ready; @@ -194,14 +183,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien throw disposedError(); } const active = await ensureSession(); - try { - return await active.client.gitStatus(); - } catch (error) { - if (isFatalBackendError(error)) { - markDead(); - } - throw error; - } + return active.client.gitStatus(); }, dispose() { disposed = true; diff --git a/tui/src/backend/client/backendLifecycle.ts b/tui/src/backend/client/backendLifecycle.ts new file mode 100644 index 00000000..ebbcebf3 --- /dev/null +++ b/tui/src/backend/client/backendLifecycle.ts @@ -0,0 +1,16 @@ +/** Lifecycle of the TUI-owned backend connection. */ +export const BackendLifecycleState = { + /** No backend has been launched yet. */ + Idle: 'idle', + /** A backend launch/connect is in flight. */ + Starting: 'starting', + /** The backend is connected and accepting requests. */ + Ready: 'ready', + /** The TUI is disposing the backend on purpose. */ + Closing: 'closing', + /** The backend exited, crashed, or a fatal transport error occurred. */ + Dead: 'dead' +} as const; + +export type BackendLifecycleState = + (typeof BackendLifecycleState)[keyof typeof BackendLifecycleState]; diff --git a/tui/src/backend/client/messageConnectionClient.ts b/tui/src/backend/client/messageConnectionClient.ts index cd80aa02..9ac2a64d 100644 --- a/tui/src/backend/client/messageConnectionClient.ts +++ b/tui/src/backend/client/messageConnectionClient.ts @@ -16,7 +16,7 @@ import { turnErrorNotification } from '@backend/protocol/messageProtocol.ts'; import { withRequestTimeout } from '@backend/client/backendClientErrors.ts'; -import { DEFAULT_REQUEST_TIMEOUT_MS } from '@constants/backend.ts'; +import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS } from '@constants/backend.ts'; /** Per-turn hooks the notification handlers dispatch to, keyed by `turnId`. */ type ActiveTurn = { @@ -30,6 +30,8 @@ type ActiveTurn = { export type MessageConnectionClientOptions = { /** Ceiling for the streaming ack response (not the whole stream). */ requestTimeoutMs?: number; + /** Ceiling while waiting for the next streaming notification after ack/delta. */ + streamIdleTimeoutMs?: number; }; /** @@ -48,6 +50,7 @@ export function createMessageConnectionClient( options: MessageConnectionClientOptions = {} ): BackendClient { const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS; const activeTurns = new Map(); connection.onNotification(tokenDeltaNotification, ({ turnId, delta }) => { @@ -74,19 +77,39 @@ export function createMessageConnectionClient( return new Promise((resolve, reject) => { let settled = false; let text = ''; + let streamIdleTimer: ReturnType | undefined; const finish = (settle: () => void): void => { if (settled) { return; } settled = true; + if (streamIdleTimer !== undefined) { + clearTimeout(streamIdleTimer); + } activeTurns.delete(turnId); settle(); }; + const refreshStreamIdleTimer = (): void => { + if (streamIdleTimer !== undefined) { + clearTimeout(streamIdleTimer); + } + streamIdleTimer = setTimeout(() => { + finish(() => + reject( + new BackendClientError( + BackendErrorKind.Timeout, + `backend stream idled for ${streamIdleTimeoutMs}ms before the turn completed` + ) + ) + ); + }, streamIdleTimeoutMs); + }; activeTurns.set(turnId, { onDelta: (delta) => { text += delta; callbacks.onDelta(delta); + refreshStreamIdleTimer(); }, complete: (finishReason) => finish(() => resolve({ kind: 'completed', text, finishReason })), @@ -102,10 +125,13 @@ export function createMessageConnectionClient( (ack) => { if (ack.status === SUBMIT_STATUS_NEEDS_CONFIGURATION) { finish(() => resolve({ kind: 'needsConfiguration' })); + return; } - // Otherwise the turn is streaming: wait for turnEnd/turnError. + // Otherwise the turn is streaming: wait for turnEnd/turnError, but + // keep a bounded idle timer so the prompt queue cannot wedge forever. + refreshStreamIdleTimer(); }, - (error: unknown) => finish(() => reject(toBackendClientError(error))) + (error: unknown) => finish(() => reject(toBackendClientError('message submit', error))) ); }); }, @@ -117,13 +143,13 @@ export function createMessageConnectionClient( ); return result.label; } catch (error) { - throw toBackendClientError(error); + throw toBackendClientError('git status', error); } } }; } -function toBackendClientError(error: unknown): BackendClientError { +function toBackendClientError(requestName: string, error: unknown): BackendClientError { if (error instanceof BackendClientError) { return error; } @@ -131,7 +157,7 @@ function toBackendClientError(error: unknown): BackendClientError { if (error instanceof ResponseError) { return new BackendClientError( BackendErrorKind.Protocol, - `backend rejected message submit: ${error.message}`, + `backend rejected ${requestName}: ${error.message}`, { cause: error } ); } diff --git a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts index bf2a8187..59eb51f6 100644 --- a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts +++ b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts @@ -23,6 +23,7 @@ import { type PairedConnections = { client: MessageConnection; server: MessageConnection; + closeTransport: () => void; dispose: () => void; }; @@ -45,6 +46,10 @@ function pairedConnections(): PairedConnections { const pair: PairedConnections = { client, server, + closeTransport: () => { + clientToServer.end(); + serverToClient.end(); + }, dispose: () => { client.dispose(); server.dispose(); @@ -158,6 +163,36 @@ describe('message protocol client', () => { await expect(submit).rejects.toBeInstanceOf(BackendClientError); await expect(submit).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); }); + + it('rejects when a streamed turn idles after the ack', async () => { + const { client, server } = pairedConnections(); + server.onRequest(messageSubmitRequest, ({ turnId }) => ({ + turnId, + status: SUBMIT_STATUS_STREAMING + })); + + const submit = createMessageConnectionClient(client, { streamIdleTimeoutMs: 20 }).submitStreaming( + { text: 'wedged' }, + { onDelta: () => {} } + ); + + await expect(submit).rejects.toMatchObject({ kind: BackendErrorKind.Timeout }); + }); + + it('rejects when the connection closes while a streamed turn is active', async () => { + const { client, server, closeTransport } = pairedConnections(); + server.onRequest(messageSubmitRequest, ({ turnId }) => { + queueMicrotask(closeTransport); + return { turnId, status: SUBMIT_STATUS_STREAMING }; + }); + + const submit = createMessageConnectionClient(client).submitStreaming( + { text: 'crash' }, + { onDelta: () => {} } + ); + + await expect(submit).rejects.toMatchObject({ kind: BackendErrorKind.Transport }); + }); }); describe('git status request', () => { @@ -189,5 +224,6 @@ describe('git status request', () => { await expect(status).rejects.toBeInstanceOf(BackendClientError); await expect(status).rejects.toMatchObject({ kind: BackendErrorKind.Protocol }); + await expect(status).rejects.toThrow('git status'); }); }); diff --git a/tui/src/bootstrap.ts b/tui/src/bootstrap.ts index 39d0628e..bc3bb797 100644 --- a/tui/src/bootstrap.ts +++ b/tui/src/bootstrap.ts @@ -15,6 +15,7 @@ import { enterAlternateScreen, leaveAlternateScreen } from '@libs/terminal/alternateScreen.ts'; +import { DISABLE_SGR_MOUSE_TRACKING } from '@libs/terminal/mouse.ts'; import { resolveSessionSeed } from '@components/AppExitSummary/resolveSessionSeed.ts'; import { windowColumnsAtom, windowRowsAtom } from '@state/ui/index.ts'; import { @@ -131,6 +132,7 @@ export async function createAppRuntime({ // redundant restore. Mirror the enter order on teardown: reset the background // and window title, then leave the alt buffer. const restoreTerminal = () => { + process.stdout.write(DISABLE_SGR_MOUSE_TRACKING); resetTerminalBackground(); resetTerminalWindowTitle(); leaveAlternateScreen(); diff --git a/tui/src/components/HomeScreen/__tests__/useCaretScrollSuppression.test.tsx b/tui/src/components/HomeScreen/__tests__/useCaretScrollSuppression.test.tsx new file mode 100644 index 00000000..d207bb7d --- /dev/null +++ b/tui/src/components/HomeScreen/__tests__/useCaretScrollSuppression.test.tsx @@ -0,0 +1,31 @@ +import { Box } from 'ink'; +import { createStore } from 'jotai'; +import { useEffect } from 'react'; +import { describe, expect, it } from 'vitest'; +import { useCaretScrollSuppression } from '@components/HomeScreen/useCaretScrollSuppression.ts'; +import { caretSuppressedWhileScrollingAtom } from '@state/ui/composer/index.ts'; +import { renderWithJotai } from '@test/renderWithJotai.tsx'; + +function ScrollProbe() { + const notifyScroll = useCaretScrollSuppression(10_000); + + useEffect(() => { + notifyScroll(); + }, [notifyScroll]); + + return ; +} + +describe('useCaretScrollSuppression', () => { + it('clears suppression when HomeScreen unmounts during the settle window', async () => { + const store = createStore(); + const screen = renderWithJotai(, store); + await Promise.resolve(); + + expect(store.get(caretSuppressedWhileScrollingAtom)).toBe(true); + + screen.unmount(); + + expect(store.get(caretSuppressedWhileScrollingAtom)).toBe(false); + }); +}); diff --git a/tui/src/components/HomeScreen/useCaretScrollSuppression.ts b/tui/src/components/HomeScreen/useCaretScrollSuppression.ts index 6d20b4f9..9e3ec084 100644 --- a/tui/src/components/HomeScreen/useCaretScrollSuppression.ts +++ b/tui/src/components/HomeScreen/useCaretScrollSuppression.ts @@ -16,11 +16,12 @@ export function useCaretScrollSuppression(settleMs: number = CARET_SCROLL_SETTLE useEffect( () => () => { + setSuppressed(false); if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } }, - [] + [setSuppressed] ); return useCallback(() => { diff --git a/tui/src/constants/backend.ts b/tui/src/constants/backend.ts index 685dda58..e9ad20e5 100644 --- a/tui/src/constants/backend.ts +++ b/tui/src/constants/backend.ts @@ -20,6 +20,9 @@ export const DEFAULT_STARTUP_TIMEOUT_MS = 10_000; /** Default ceiling for a single message-submit round trip. */ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; +/** Default idle ceiling while waiting for streaming turn notifications. */ +export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 30_000; + /** Captured Cargo stderr is capped so a noisy build cannot exhaust memory. */ export const BUILD_STDERR_CAP_BYTES = 16 * 1024; diff --git a/tui/src/libs/tui/bodyRows.ts b/tui/src/libs/tui/bodyRows.ts index 1a50e257..ede60966 100644 --- a/tui/src/libs/tui/bodyRows.ts +++ b/tui/src/libs/tui/bodyRows.ts @@ -3,6 +3,7 @@ import { UPPER_HALF_BLOCK } from '@libs/tui/backgroundBlock.ts'; import { BodyEntryKind } from '@constants/bodyEntry.ts'; +import { wrapBodyText } from '@libs/tui/wrapBodyText.ts'; import { theme } from '@theme/themeConfig.ts'; export type BodyEntry = { @@ -177,25 +178,3 @@ function labelForEntry(entry: BodyEntry): string { return entry.text; } - -// Splits on hard line breaks (`\n`, normalizing `\r\n`/`\r` first) so multi-line -// backend output, errors, and prompts all keep their author-intended rows, then -// wraps each line to `columns`. The display sanitizer preserves `\n` as a real -// layout character, so newlines here are trusted content rather than escaped. -function wrapBodyText(text: string, columns: number): string[] { - const wrappedRows: string[] = []; - const hardLines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); - - for (const line of hardLines) { - if (line.length === 0) { - wrappedRows.push(''); - continue; - } - - for (let start = 0; start < line.length; start += columns) { - wrappedRows.push(line.slice(start, start + columns)); - } - } - - return wrappedRows; -} diff --git a/tui/src/libs/tui/wrapBodyText.ts b/tui/src/libs/tui/wrapBodyText.ts new file mode 100644 index 00000000..30b73ffc --- /dev/null +++ b/tui/src/libs/tui/wrapBodyText.ts @@ -0,0 +1,21 @@ +// Splits on hard line breaks (`\n`, normalizing `\r\n`/`\r` first) so multi-line +// backend output, errors, and prompts all keep their author-intended rows, then +// wraps each line to `columns`. The display sanitizer preserves `\n` as a real +// layout character, so newlines here are trusted content rather than escaped. +export function wrapBodyText(text: string, columns: number): string[] { + const wrappedRows: string[] = []; + const hardLines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + + for (const line of hardLines) { + if (line.length === 0) { + wrappedRows.push(''); + continue; + } + + for (let start = 0; start < line.length; start += columns) { + wrappedRows.push(line.slice(start, start + columns)); + } + } + + return wrappedRows; +} diff --git a/tui/src/state/promptQueue/__tests__/atoms.test.ts b/tui/src/state/promptQueue/__tests__/atoms.test.ts index 160e7b64..423e7f2e 100644 --- a/tui/src/state/promptQueue/__tests__/atoms.test.ts +++ b/tui/src/state/promptQueue/__tests__/atoms.test.ts @@ -6,8 +6,20 @@ import { enqueuePromptAtom, promptQueueAtom } from '@state/promptQueue/atoms.ts'; +import { backendClientAtom } from '@state/global/index.ts'; import { BACKEND_UNAVAILABLE_MESSAGE } from '@libs/promptQueue/promptQueue.ts'; import { bodyScrollOffsetRowsAtom, submittedPromptEntriesAtom } from '@state/ui/index.ts'; +import type { BackendClient, StreamOutcome } from '@contracts/backend/index.ts'; + +async function waitForCondition(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (condition()) { + return; + } + await Promise.resolve(); + } + throw new Error('condition was not met'); +} describe('enqueuePromptAtom', () => { it('settles a visible error entry when no backend client is wired into the seam', async () => { @@ -20,6 +32,37 @@ describe('enqueuePromptAtom', () => { expect(errorEntry).toBeDefined(); expect(errorEntry?.text).toContain(BACKEND_UNAVAILABLE_MESSAGE); }); + + it('does not promote queued prompts when a cleared active turn settles late', async () => { + const store = createStore(); + const resolvers: Array<(outcome: StreamOutcome) => void> = []; + const backendClient: BackendClient = { + gitStatus: async () => null, + submitStreaming: async () => + new Promise((resolve) => { + resolvers.push(resolve); + }) + }; + store.set(backendClientAtom, backendClient); + + const first = store.set(enqueuePromptAtom, 'first'); + store.set(clearTranscriptAtom); + const second = store.set(enqueuePromptAtom, 'second'); + const third = store.set(enqueuePromptAtom, 'third'); + + resolvers[0]?.({ kind: 'needsConfiguration' }); + await waitForCondition(() => resolvers.length === 2); + + expect(store.get(promptQueueAtom).map((item) => ({ text: item.text, state: item.state }))).toEqual([ + { text: 'second', state: 'active' }, + { text: 'third', state: 'queued' } + ]); + + resolvers[1]?.({ kind: 'needsConfiguration' }); + await waitForCondition(() => resolvers.length === 3); + resolvers[2]?.({ kind: 'needsConfiguration' }); + await Promise.all([first, second, third]); + }); }); describe('clearTranscriptAtom', () => { diff --git a/tui/src/state/promptQueue/atoms.ts b/tui/src/state/promptQueue/atoms.ts index 07cd37ad..de0ab381 100644 --- a/tui/src/state/promptQueue/atoms.ts +++ b/tui/src/state/promptQueue/atoms.ts @@ -155,11 +155,13 @@ function updateStreamingText( function settleActive(set: Setter, id: number, result: BackendResult): void { set(promptQueueAtom, (queue) => { let promoted = false; + let settledTarget = false; return queue.map((item) => { if (item.id === id) { + settledTarget = true; return { ...item, state: 'settled' as const, result }; } - if (!promoted && item.state === 'queued') { + if (settledTarget && !promoted && item.state === 'queued') { promoted = true; return { ...item, state: 'active' as const }; } From 3607a0d4d6bf3ae3282e7bfe53d4595d9da1364a Mon Sep 17 00:00:00 2001 From: Kefei Qian Date: Tue, 14 Jul 2026 18:06:19 +0800 Subject: [PATCH 03/52] feat(git): show pull request status in cwd line Expose an optional GitHub pull request label from the Rust git status request and render it as a separate cwd segment in the TUI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c551aab9-42c3-43ae-b8ff-3a9cd25d6f23 --- src/backend.rs | 4 +- src/git.rs | 182 +----------------- src/git/command.rs | 44 +++++ src/git/status.rs | 177 +++++++++++++++++ src/git/{ => status}/tests.rs | 12 +- src/protocol.rs | 3 +- tests/git_status.rs | 5 + .../__tests__/components/HomeScreen.test.tsx | 20 +- tui/src/backend/client/backendClient.ts | 2 +- .../backend/client/messageConnectionClient.ts | 9 +- .../__tests__/messageProtocol.test.ts | 12 +- tui/src/components/CwdLine.tsx | 6 +- tui/src/contracts/backend/client.ts | 16 +- tui/src/contracts/backend/messages.ts | 12 +- tui/src/libs/tui/cwdLine.ts | 17 +- tui/src/state/ui/__tests__/gitStatus.test.ts | 19 +- tui/src/state/ui/atoms.ts | 4 +- tui/src/state/ui/gitStatus.ts | 14 +- 18 files changed, 330 insertions(+), 228 deletions(-) create mode 100644 src/git/command.rs create mode 100644 src/git/status.rs rename src/git/{ => status}/tests.rs (84%) diff --git a/src/backend.rs b/src/backend.rs index 1604c340..a5bd53b4 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -162,10 +162,12 @@ fn spawn_git_status(request: Request, connection: &Connection) { let id = request.id; let sender = connection.sender.clone(); thread::spawn(move || { + let status = git::status(); let response = Response::new_ok( id, GitStatusResult { - label: git::status_label(), + label: status.as_ref().map(|status| status.label.clone()), + pull_request_label: status.and_then(|status| status.pull_request_label), }, ); let _ = sender.send(Message::Response(response)); diff --git a/src/git.rs b/src/git.rs index 6874b651..2e70cc06 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,178 +1,12 @@ -//! Git working-tree status for the current workspace. +//! Git and GitHub status for the current workspace. //! //! The backend runs in the workspace directory (the TUI spawns it with -//! `cwd = workspaceCwd`), so `git status` is invoked in the inherited process -//! cwd — no path argument is threaded through the protocol. Parsing and label -//! formatting live here in the core runtime so the headless CLI and the TUI show -//! the same string; the TUI only renders whatever label this returns. +//! `cwd = workspaceCwd`), so git/GitHub commands are invoked in the inherited +//! process cwd — no path argument is threaded through the protocol. Parsing and +//! label formatting live here in the core runtime so the headless CLI and the +//! TUI stay consistent. -use std::{ - io::Read, - process::{Command, Stdio}, - thread, - time::{Duration, Instant}, -}; +mod command; +mod status; -/// Branch glyph prefixing every status label. -const GIT_BRANCH_ICON: &str = "⎇"; -/// Flag appended when the worktree has unstaged changes. -const UNSTAGED_CHANGE_FLAG: &str = "*"; -/// Flag appended when the index has staged changes. -const STAGED_CHANGE_FLAG: &str = "+"; -/// Flag appended when the worktree has untracked files. -const UNTRACKED_CHANGE_FLAG: &str = "%"; -/// Prefix of the porcelain `--branch` header line. -const STATUS_BRANCH_PREFIX: &str = "## "; -/// Separator between the local branch and its upstream in the header line. -const STATUS_UPSTREAM_SEPARATOR: &str = "..."; -/// Header text for a repository with no commits yet (`## No commits yet on X`). -const NO_COMMITS_BRANCH_PREFIX: &str = "No commits yet on "; -/// Header text emitted for a detached HEAD. -const DETACHED_HEAD_STATUS: &str = "HEAD (no branch)"; -/// Ceiling for the `git status` call before it is treated as unavailable. -const GIT_STATUS_TIMEOUT: Duration = Duration::from_secs(2); -/// Poll interval while waiting for a bounded `git status` child. -const GIT_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(10); -/// Parsed porcelain status of the workspace worktree. -#[derive(Debug, Eq, PartialEq)] -struct GitStatus { - branch: String, - has_unstaged_changes: bool, - has_staged_changes: bool, - has_untracked_changes: bool, -} - -/// Returns the formatted git status label for the workspace, or `None` when the -/// directory is not a git repository or `git` is unavailable. Blocking; call it -/// off the backend's request loop (e.g. on a thread). -#[must_use] -pub fn status_label() -> Option { - let mut child = Command::new("git") - .args(["status", "--porcelain=v1", "--branch"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - - let deadline = Instant::now() + GIT_STATUS_TIMEOUT; - loop { - match child.try_wait() { - Ok(Some(status)) => { - if !status.success() { - return None; - } - let mut stdout = String::new(); - child.stdout.take()?.read_to_string(&mut stdout).ok()?; - return parse_status_label(&stdout); - } - Ok(None) if Instant::now() >= deadline => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - Ok(None) => thread::sleep(GIT_STATUS_POLL_INTERVAL), - Err(_) => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - } - } -} - -/// Formats the porcelain `git status --branch` output into a display label, or -/// `None` when no branch header line is present. -fn parse_status_label(porcelain: &str) -> Option { - parse_status(porcelain).map(|status| format_label(&status)) -} - -fn parse_status(porcelain: &str) -> Option { - let lines: Vec<&str> = porcelain.lines().filter(|line| !line.is_empty()).collect(); - let branch_line = lines - .iter() - .find(|line| line.starts_with(STATUS_BRANCH_PREFIX))?; - let branch = parse_branch_name(branch_line); - - let mut status = GitStatus { - branch, - has_unstaged_changes: false, - has_staged_changes: false, - has_untracked_changes: false, - }; - - for line in &lines { - if line.starts_with(STATUS_BRANCH_PREFIX) { - continue; - } - let code = line.as_bytes(); - if let Some(&staged) = code.first() - && is_change_flag(staged) - { - status.has_staged_changes = true; - } - if let Some(&unstaged) = code.get(1) - && is_change_flag(unstaged) - { - status.has_unstaged_changes = true; - } - if line.starts_with("??") { - status.has_untracked_changes = true; - } - } - - Some(status) -} - -/// Whether a porcelain XY status byte marks a tracked change — i.e. not clean -/// (` `), untracked (`?`), or ignored (`!`). -fn is_change_flag(code: u8) -> bool { - code != b' ' && code != b'?' && code != b'!' -} - -/// Extracts the branch name from the porcelain `## ...` header line. -fn parse_branch_name(branch_line: &str) -> String { - let branch_status = &branch_line[STATUS_BRANCH_PREFIX.len()..]; - - if let Some(rest) = branch_status.strip_prefix(NO_COMMITS_BRANCH_PREFIX) { - return rest.to_owned(); - } - if branch_status == DETACHED_HEAD_STATUS { - return "HEAD".to_owned(); - } - - let without_upstream = branch_status - .split(STATUS_UPSTREAM_SEPARATOR) - .next() - .unwrap_or(branch_status); - without_upstream - .split(" [") - .next() - .unwrap_or(without_upstream) - .to_owned() -} - -fn format_label(status: &GitStatus) -> String { - format!( - "{GIT_BRANCH_ICON} {}{}", - status.branch, - format_flags(status) - ) -} - -fn format_flags(status: &GitStatus) -> String { - let mut flags = String::new(); - if status.has_unstaged_changes { - flags.push_str(UNSTAGED_CHANGE_FLAG); - } - if status.has_staged_changes { - flags.push_str(STAGED_CHANGE_FLAG); - } - if status.has_untracked_changes { - flags.push_str(UNTRACKED_CHANGE_FLAG); - } - flags -} - -#[cfg(test)] -mod tests; +pub use status::{WorkspaceGitStatus, status, status_label}; diff --git a/src/git/command.rs b/src/git/command.rs new file mode 100644 index 00000000..7aaa4471 --- /dev/null +++ b/src/git/command.rs @@ -0,0 +1,44 @@ +use std::{ + io::Read, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +/// Poll interval while waiting for a bounded status child. +const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); + +pub(super) fn run_stdout(program: &str, args: &[&str], timeout: Duration) -> Option { + let mut child = Command::new(program) + .args(args.iter().copied()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + let mut stdout = String::new(); + child.stdout.take()?.read_to_string(&mut stdout).ok()?; + return Some(stdout); + } + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(None) => thread::sleep(COMMAND_POLL_INTERVAL), + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } +} diff --git a/src/git/status.rs b/src/git/status.rs new file mode 100644 index 00000000..15504be9 --- /dev/null +++ b/src/git/status.rs @@ -0,0 +1,177 @@ +use std::time::Duration; + +use super::command::run_stdout; + +/// Command used for local repository status. +const GIT_COMMAND: &str = "git"; +/// Command used for GitHub pull-request lookup. +const GITHUB_COMMAND: &str = "gh"; +/// Arguments that ask git for stable branch/status porcelain. +const GIT_STATUS_ARGS: &[&str] = &["status", "--porcelain=v1", "--branch"]; +/// Arguments that ask GitHub CLI for the current branch's pull-request number. +const PULL_REQUEST_NUMBER_ARGS: &[&str] = &["pr", "view", "--json", "number", "--jq", ".number"]; +/// Branch glyph prefixing every status label. +const GIT_BRANCH_ICON: &str = "⎇"; +/// Flag appended when the worktree has unstaged changes. +const UNSTAGED_CHANGE_FLAG: &str = "*"; +/// Flag appended when the index has staged changes. +const STAGED_CHANGE_FLAG: &str = "+"; +/// Flag appended when the worktree has untracked files. +const UNTRACKED_CHANGE_FLAG: &str = "%"; +/// Prefix of the porcelain `--branch` header line. +const STATUS_BRANCH_PREFIX: &str = "## "; +/// Separator between the local branch and its upstream in the header line. +const STATUS_UPSTREAM_SEPARATOR: &str = "..."; +/// Header text for a repository with no commits yet (`## No commits yet on X`). +const NO_COMMITS_BRANCH_PREFIX: &str = "No commits yet on "; +/// Header text emitted for a detached HEAD. +const DETACHED_HEAD_STATUS: &str = "HEAD (no branch)"; +/// Prefix for a pull-request status segment. +const PULL_REQUEST_LABEL_PREFIX: &str = "#"; +/// Ceiling for each git/GitHub status command before it is treated as unavailable. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(2); +/// Formatted workspace source-control status returned to clients. +#[derive(Debug, Eq, PartialEq)] +pub struct WorkspaceGitStatus { + pub label: String, + pub pull_request_label: Option, +} + +/// Parsed porcelain status of the workspace worktree. +#[derive(Debug, Eq, PartialEq)] +struct GitStatus { + branch: String, + has_unstaged_changes: bool, + has_staged_changes: bool, + has_untracked_changes: bool, +} + +/// Returns the formatted git/GitHub status for the workspace, or `None` when +/// the directory is not a git repository or `git` is unavailable. Blocking; call +/// it off the backend's request loop (e.g. on a thread). +#[must_use] +pub fn status() -> Option { + let parsed_status = read_status()?; + + Some(WorkspaceGitStatus { + label: format_label(&parsed_status), + pull_request_label: pull_request_number().map(format_pull_request_label), + }) +} + +/// Returns only the formatted git status label for callers that do not need the +/// optional pull-request label. +#[must_use] +pub fn status_label() -> Option { + read_status().map(|parsed_status| format_label(&parsed_status)) +} + +fn read_status() -> Option { + let porcelain = run_stdout(GIT_COMMAND, GIT_STATUS_ARGS, COMMAND_TIMEOUT)?; + parse_status(&porcelain) +} + +fn pull_request_number() -> Option { + let stdout = run_stdout(GITHUB_COMMAND, PULL_REQUEST_NUMBER_ARGS, COMMAND_TIMEOUT)?; + parse_pull_request_number(&stdout) +} + +fn parse_status(porcelain: &str) -> Option { + let lines: Vec<&str> = porcelain.lines().filter(|line| !line.is_empty()).collect(); + let branch_line = lines + .iter() + .find(|line| line.starts_with(STATUS_BRANCH_PREFIX))?; + let branch = parse_branch_name(branch_line); + + let mut status = GitStatus { + branch, + has_unstaged_changes: false, + has_staged_changes: false, + has_untracked_changes: false, + }; + + for line in &lines { + if line.starts_with(STATUS_BRANCH_PREFIX) { + continue; + } + if line.starts_with("??") { + status.has_untracked_changes = true; + continue; + } + + let code = line.as_bytes(); + if let Some(&staged) = code.first() + && is_change_flag(staged) + { + status.has_staged_changes = true; + } + if let Some(&unstaged) = code.get(1) + && is_change_flag(unstaged) + { + status.has_unstaged_changes = true; + } + } + + Some(status) +} + +/// Whether a porcelain XY status byte marks a tracked change — i.e. not clean +/// (` `), untracked (`?`), or ignored (`!`). +fn is_change_flag(code: u8) -> bool { + code != b' ' && code != b'?' && code != b'!' +} + +/// Extracts the branch name from the porcelain `## ...` header line. +fn parse_branch_name(branch_line: &str) -> String { + let branch_status = &branch_line[STATUS_BRANCH_PREFIX.len()..]; + + if let Some(rest) = branch_status.strip_prefix(NO_COMMITS_BRANCH_PREFIX) { + return rest.to_owned(); + } + if branch_status == DETACHED_HEAD_STATUS { + return "HEAD".to_owned(); + } + + let without_upstream = branch_status + .split(STATUS_UPSTREAM_SEPARATOR) + .next() + .unwrap_or(branch_status); + without_upstream + .split(" [") + .next() + .unwrap_or(without_upstream) + .to_owned() +} + +fn parse_pull_request_number(stdout: &str) -> Option { + stdout.trim().parse().ok() +} + +fn format_label(status: &GitStatus) -> String { + format!( + "{GIT_BRANCH_ICON} {}{}", + status.branch, + format_flags(status) + ) +} + +fn format_flags(status: &GitStatus) -> String { + let mut flags = String::new(); + if status.has_unstaged_changes { + flags.push_str(UNSTAGED_CHANGE_FLAG); + } + if status.has_staged_changes { + flags.push_str(STAGED_CHANGE_FLAG); + } + if status.has_untracked_changes { + flags.push_str(UNTRACKED_CHANGE_FLAG); + } + flags +} + +fn format_pull_request_label(number: u32) -> String { + format!("{PULL_REQUEST_LABEL_PREFIX}{number}") +} + +#[cfg(test)] +mod tests; diff --git a/src/git/tests.rs b/src/git/status/tests.rs similarity index 84% rename from src/git/tests.rs rename to src/git/status/tests.rs index 51e67ca6..624c04fb 100644 --- a/src/git/tests.rs +++ b/src/git/status/tests.rs @@ -1,4 +1,8 @@ -use super::{format_label, parse_status, parse_status_label}; +use super::{format_label, format_pull_request_label, parse_pull_request_number, parse_status}; + +fn parse_status_label(porcelain: &str) -> Option { + parse_status(porcelain).map(|status| format_label(&status)) +} #[test] fn formats_branch_with_staged_unstaged_and_untracked_flags() { @@ -71,6 +75,12 @@ fn treats_untracked_entries_as_untracked_only() { assert_eq!(format_label(&status), "⎇ main%"); } +#[test] +fn formats_pull_request_label_from_number() { + assert_eq!(parse_pull_request_number("3\n"), Some(3)); + assert_eq!(format_pull_request_label(3), "#3"); +} + #[test] fn returns_none_without_a_branch_header_line() { assert!(parse_status_label(" M src/git.rs\n").is_none()); diff --git a/src/protocol.rs b/src/protocol.rs index 5179a4ba..7be6e4d8 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -112,10 +112,11 @@ pub struct TurnErrorParams { /// Result for `kqode.git.status`: the formatted working-tree label, or `null` /// when the workspace is not a git repository (or `git` could not be queried). -/// The backend owns parsing and formatting; the client renders `label` verbatim. +/// `pullRequestLabel` is an optional GitHub PR segment such as `#3`. /// Kept in lockstep with the TypeScript `GitStatusResult`. #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitStatusResult { pub label: Option, + pub pull_request_label: Option, } diff --git a/tests/git_status.rs b/tests/git_status.rs index 0489f948..ceb6a247 100644 --- a/tests/git_status.rs +++ b/tests/git_status.rs @@ -35,4 +35,9 @@ fn git_status_returns_a_formatted_label_for_the_workspace() { label.starts_with("⎇ "), "label should start with the branch glyph: {label}" ); + assert!( + frames[0]["result"]["pullRequestLabel"].is_null() + || frames[0]["result"]["pullRequestLabel"].is_string(), + "pull request label should be null or a string" + ); } diff --git a/tui/src/__tests__/components/HomeScreen.test.tsx b/tui/src/__tests__/components/HomeScreen.test.tsx index 8cce5607..65450a95 100644 --- a/tui/src/__tests__/components/HomeScreen.test.tsx +++ b/tui/src/__tests__/components/HomeScreen.test.tsx @@ -7,11 +7,12 @@ import { App } from '@/App.tsx'; import { BodyPane } from '@components/BodyPane.tsx'; import { formatDisplayCwd } from '@libs/tui/cwdLine.ts'; import type { BodyEntry } from '@libs/tui/bodyRows.ts'; +import type { GitStatus } from '@contracts/backend/index.ts'; import { PROMPT_MAX_BYTES } from '@libs/composer/promptText.ts'; import { bodyEntriesAtom, columnsTestOverrideAtom, - gitStatusLabelAtom, + gitStatusAtom, rowsTestOverrideAtom } from '@state/ui/index.ts'; import { productVersionAtom, workspaceCwdAtom } from '@state/global/index.ts'; @@ -29,7 +30,7 @@ const projectsKQode = path.join('Projects', 'KQode'); type RenderHomeScreenOptions = { productVersion?: string; workspaceCwd?: string; - gitStatusLabel?: string; + gitStatus?: GitStatus; columns?: number; rows?: number; bodyEntries?: readonly BodyEntry[]; @@ -38,7 +39,7 @@ type RenderHomeScreenOptions = { function renderHomeScreen({ productVersion = '0.1.0', workspaceCwd: screenWorkspaceCwd = workspaceCwd, - gitStatusLabel, + gitStatus, columns, rows, bodyEntries @@ -46,8 +47,8 @@ function renderHomeScreen({ const store = createStore(); store.set(productVersionAtom, productVersion); store.set(workspaceCwdAtom, screenWorkspaceCwd); - if (gitStatusLabel !== undefined) { - store.set(gitStatusLabelAtom, gitStatusLabel); + if (gitStatus !== undefined) { + store.set(gitStatusAtom, gitStatus); } if (columns !== undefined) { store.set(columnsTestOverrideAtom, columns); @@ -214,9 +215,12 @@ describe('HomeScreen', () => { expect(outputRows.at(2)).toContain(' IJKLMNOPQRSTUVWXYZ'); }); - it('formats the cwd row with a home-relative path and bracketed git status', () => { + it('formats the cwd row with home-relative path, git status, and PR status', () => { const { lastFrame } = renderHomeScreen({ - gitStatusLabel: '⎇ feat/first-ink-tui-jsonrpc-backend*+%', + gitStatus: { + label: '⎇ feat/first-ink-tui-jsonrpc-backend*+%', + pullRequestLabel: '#3' + }, columns: 100, rows: 20 }); @@ -224,7 +228,7 @@ describe('HomeScreen', () => { const output = lastFrame() ?? ''; expect(formatDisplayCwd(workspaceCwd)).toBe(displayCwd); - expect(output).toContain(`${displayCwd} [⎇ feat/first-ink-tui-jsonrpc-backend*+%]`); + expect(output).toContain(`${displayCwd} [⎇ feat/first-ink-tui-jsonrpc-backend*+%] [#3]`); expect(output).not.toContain('cwd '); }); diff --git a/tui/src/backend/client/backendClient.ts b/tui/src/backend/client/backendClient.ts index 0f4eb138..191447a1 100644 --- a/tui/src/backend/client/backendClient.ts +++ b/tui/src/backend/client/backendClient.ts @@ -178,7 +178,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien throw error; } }, - async gitStatus(): Promise { + async gitStatus() { if (disposed) { throw disposedError(); } diff --git a/tui/src/backend/client/messageConnectionClient.ts b/tui/src/backend/client/messageConnectionClient.ts index 9ac2a64d..b10ee305 100644 --- a/tui/src/backend/client/messageConnectionClient.ts +++ b/tui/src/backend/client/messageConnectionClient.ts @@ -135,13 +135,18 @@ export function createMessageConnectionClient( ); }); }, - async gitStatus(): Promise { + async gitStatus() { try { const result = await withRequestTimeout( connection.sendRequest(gitStatusRequest), requestTimeoutMs ); - return result.label; + return result.label === null + ? null + : { + label: result.label, + pullRequestLabel: result.pullRequestLabel ?? undefined + }; } catch (error) { throw toBackendClientError('git status', error); } diff --git a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts index 59eb51f6..1da57c36 100644 --- a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts +++ b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts @@ -198,20 +198,20 @@ describe('message protocol client', () => { describe('git status request', () => { it('resolves the formatted label the backend returns', async () => { const { client, server } = pairedConnections(); - server.onRequest(gitStatusRequest, () => ({ label: '⎇ main*' })); + server.onRequest(gitStatusRequest, () => ({ label: '⎇ main*', pullRequestLabel: '#3' })); - const label = await createMessageConnectionClient(client).gitStatus(); + const status = await createMessageConnectionClient(client).gitStatus(); - expect(label).toBe('⎇ main*'); + expect(status).toEqual({ label: '⎇ main*', pullRequestLabel: '#3' }); }); it('resolves null when the workspace is not a git repository', async () => { const { client, server } = pairedConnections(); - server.onRequest(gitStatusRequest, () => ({ label: null })); + server.onRequest(gitStatusRequest, () => ({ label: null, pullRequestLabel: null })); - const label = await createMessageConnectionClient(client).gitStatus(); + const status = await createMessageConnectionClient(client).gitStatus(); - expect(label).toBeNull(); + expect(status).toBeNull(); }); it('surfaces a JSON-RPC error as a typed protocol client error', async () => { diff --git a/tui/src/components/CwdLine.tsx b/tui/src/components/CwdLine.tsx index d0ee7fff..ae51d747 100644 --- a/tui/src/components/CwdLine.tsx +++ b/tui/src/components/CwdLine.tsx @@ -1,17 +1,17 @@ import { Box, Text } from 'ink'; import { useAtomValue } from 'jotai'; import { formatCwdLine } from '@libs/tui/cwdLine.ts'; -import { gitStatusLabelAtom } from '@state/ui/index.ts'; +import { gitStatusAtom } from '@state/ui/index.ts'; import { workspaceCwdAtom } from '@state/global/index.ts'; import { theme } from '@theme/themeConfig.ts'; export function CwdLine() { const workspaceCwd = useAtomValue(workspaceCwdAtom); - const gitStatusLabel = useAtomValue(gitStatusLabelAtom); + const gitStatus = useAtomValue(gitStatusAtom); return ( - {formatCwdLine(workspaceCwd, gitStatusLabel)} + {formatCwdLine(workspaceCwd, gitStatus)} ); } diff --git a/tui/src/contracts/backend/client.ts b/tui/src/contracts/backend/client.ts index 0b951ea5..20d7a65d 100644 --- a/tui/src/contracts/backend/client.ts +++ b/tui/src/contracts/backend/client.ts @@ -48,6 +48,12 @@ export type StreamOutcome = | { kind: 'error'; errorKind: string; message: string } | { kind: 'needsConfiguration' }; +/** Workspace source-control status already formatted by the Rust backend. */ +export type GitStatus = { + label: string; + pullRequestLabel?: string; +}; + /** * Narrow backend seam the TUI uses for streaming chat turns. * @@ -60,10 +66,10 @@ export type StreamOutcome = export type BackendClient = { submitStreaming(params: StreamSubmitParams, callbacks: StreamCallbacks): Promise; /** - * Fetches the workspace git status label (e.g. `⎇ main*`), or `null` when the - * workspace is not a git repository or `git` could not be queried. Rejects - * with a {@link BackendClientError} on transport/timeout failure. The backend - * formats the label; the TUI renders it verbatim. + * Fetches the workspace git/PR status, or `null` when the workspace is not a + * git repository or `git` could not be queried. Rejects with a + * {@link BackendClientError} on transport/timeout failure. The backend formats + * display segments; the TUI only wraps them. */ - gitStatus(): Promise; + gitStatus(): Promise; }; diff --git a/tui/src/contracts/backend/messages.ts b/tui/src/contracts/backend/messages.ts index 8d3c2580..6b351682 100644 --- a/tui/src/contracts/backend/messages.ts +++ b/tui/src/contracts/backend/messages.ts @@ -15,11 +15,11 @@ export const MESSAGE_SUBMIT_METHOD = 'kqode.message.submit'; /** - * KQode-owned JSON-RPC method returning the workspace git status label. + * KQode-owned JSON-RPC method returning the workspace git/PR status. * * Must match `RpcMethod::GitStatus` (via `RpcMethod::as_str`) in - * `src/protocol.rs`. The backend runs `git` in the workspace and formats the - * label; the TUI renders the returned string verbatim. + * `src/protocol.rs`. The backend runs git/GitHub status commands in the + * workspace and formats the returned display segments. */ export const GIT_STATUS_METHOD = 'kqode.git.status'; @@ -103,9 +103,11 @@ export type TurnErrorParams = { /** * Result for `kqode.git.status`: the formatted working-tree label (e.g. * `⎇ main*`), or `null` when the workspace is not a git repository or `git` - * could not be queried. The Rust backend owns parsing and formatting - * (`GitStatusResult` in `src/protocol.rs`); keep the two shapes in lockstep. + * could not be queried. `pullRequestLabel` is an optional GitHub PR segment + * such as `#3`. The Rust backend owns parsing and formatting (`GitStatusResult` + * in `src/protocol.rs`); keep the two shapes in lockstep. */ export type GitStatusResult = { label: string | null; + pullRequestLabel: string | null; }; diff --git a/tui/src/libs/tui/cwdLine.ts b/tui/src/libs/tui/cwdLine.ts index 7f2ce8af..31f415d1 100644 --- a/tui/src/libs/tui/cwdLine.ts +++ b/tui/src/libs/tui/cwdLine.ts @@ -1,15 +1,22 @@ import os from 'node:os'; import path from 'node:path'; +import type { GitStatus } from '@contracts/backend/index.ts'; -export function formatCwdLine(workspaceCwd: string, gitStatusLabel?: string): string { +export function formatCwdLine(workspaceCwd: string, gitStatus?: GitStatus): string { const cwdSegment = formatDisplayCwd(workspaceCwd); - const gitSegment = gitStatusLabel === undefined ? '' : ` [${gitStatusLabel}]`; - return `${cwdSegment}${gitSegment}`; + const gitSegment = gitStatus === undefined ? '' : ` [${gitStatus.label}]`; + const pullRequestSegment = + gitStatus?.pullRequestLabel === undefined ? '' : ` [${gitStatus.pullRequestLabel}]`; + return `${cwdSegment}${gitSegment}${pullRequestSegment}`; } -export function countCwdRows(workspaceCwd: string, gitStatusLabel: string | undefined, columns: number): number { +export function countCwdRows( + workspaceCwd: string, + gitStatus: GitStatus | undefined, + columns: number +): number { const visibleColumns = Math.max(1, columns); - return Math.max(1, Math.ceil(formatCwdLine(workspaceCwd, gitStatusLabel).length / visibleColumns)); + return Math.max(1, Math.ceil(formatCwdLine(workspaceCwd, gitStatus).length / visibleColumns)); } export function formatDisplayCwd(workspaceCwd: string, homeDir = os.homedir()): string { diff --git a/tui/src/state/ui/__tests__/gitStatus.test.ts b/tui/src/state/ui/__tests__/gitStatus.test.ts index 3c3e5e50..e4d433b8 100644 --- a/tui/src/state/ui/__tests__/gitStatus.test.ts +++ b/tui/src/state/ui/__tests__/gitStatus.test.ts @@ -2,7 +2,7 @@ import { createStore } from 'jotai'; import { describe, expect, it, vi } from 'vitest'; import type { BackendClient } from '@contracts/backend/index.ts'; import { backendClientAtom } from '@state/global/index.ts'; -import { gitStatusLabelAtom, refreshGitStatusAtom } from '@state/ui/gitStatus.ts'; +import { gitStatusAtom, refreshGitStatusAtom } from '@state/ui/gitStatus.ts'; function clientWithGitStatus(gitStatus: BackendClient['gitStatus']): BackendClient { return { submitStreaming: vi.fn(), gitStatus }; @@ -11,26 +11,29 @@ function clientWithGitStatus(gitStatus: BackendClient['gitStatus']): BackendClie describe('refreshGitStatusAtom', () => { it('stores the label the backend returns', async () => { const store = createStore(); - store.set(backendClientAtom, clientWithGitStatus(async () => '⎇ main*')); + store.set( + backendClientAtom, + clientWithGitStatus(async () => ({ label: '⎇ main*', pullRequestLabel: '#3' })) + ); await store.set(refreshGitStatusAtom); - expect(store.get(gitStatusLabelAtom)).toBe('⎇ main*'); + expect(store.get(gitStatusAtom)).toEqual({ label: '⎇ main*', pullRequestLabel: '#3' }); }); it('clears the label to undefined when the workspace is not a git repository', async () => { const store = createStore(); - store.set(gitStatusLabelAtom, '⎇ main'); + store.set(gitStatusAtom, { label: '⎇ main' }); store.set(backendClientAtom, clientWithGitStatus(async () => null)); await store.set(refreshGitStatusAtom); - expect(store.get(gitStatusLabelAtom)).toBeUndefined(); + expect(store.get(gitStatusAtom)).toBeUndefined(); }); it('keeps the last known label when the request fails', async () => { const store = createStore(); - store.set(gitStatusLabelAtom, '⎇ main'); + store.set(gitStatusAtom, { label: '⎇ main' }); store.set( backendClientAtom, clientWithGitStatus(async () => { @@ -40,7 +43,7 @@ describe('refreshGitStatusAtom', () => { await store.set(refreshGitStatusAtom); - expect(store.get(gitStatusLabelAtom)).toBe('⎇ main'); + expect(store.get(gitStatusAtom)).toEqual({ label: '⎇ main' }); }); it('is a no-op when no backend client is wired', async () => { @@ -48,6 +51,6 @@ describe('refreshGitStatusAtom', () => { await store.set(refreshGitStatusAtom); - expect(store.get(gitStatusLabelAtom)).toBeUndefined(); + expect(store.get(gitStatusAtom)).toBeUndefined(); }); }); diff --git a/tui/src/state/ui/atoms.ts b/tui/src/state/ui/atoms.ts index 55c5a764..81822404 100644 --- a/tui/src/state/ui/atoms.ts +++ b/tui/src/state/ui/atoms.ts @@ -13,7 +13,7 @@ import { clamp } from '@libs/math/clamp.ts'; import { workspaceCwdAtom } from '@state/global/index.ts'; import { bodyEntriesAtom } from '@state/ui/body.ts'; import { columnsAtom, rowsAtom } from '@state/ui/dimensions.ts'; -import { gitStatusLabelAtom } from '@state/ui/gitStatus.ts'; +import { gitStatusAtom } from '@state/ui/gitStatus.ts'; import { commandMenuDesiredRowsAtom, commandMenuOpenAtom } from '@state/ui/commands/index.ts'; import { PROMPT_PREFIX } from '@constants/ui.ts'; import { @@ -39,7 +39,7 @@ export const cwdRowsAtom = atom((get) => { if (get(commandMenuOpenAtom)) { return 0; } - return countCwdRows(get(workspaceCwdAtom), get(gitStatusLabelAtom), get(columnsAtom)); + return countCwdRows(get(workspaceCwdAtom), get(gitStatusAtom), get(columnsAtom)); }); export const displayedBodyEntriesAtom = atom((get) => { diff --git a/tui/src/state/ui/gitStatus.ts b/tui/src/state/ui/gitStatus.ts index ca2fb15c..78633039 100644 --- a/tui/src/state/ui/gitStatus.ts +++ b/tui/src/state/ui/gitStatus.ts @@ -1,16 +1,18 @@ import { atom } from 'jotai'; +import type { GitStatus } from '@contracts/backend/index.ts'; import { backendClientAtom } from '@state/global/backend.ts'; -/** The latest workspace git status label, or `undefined` before the first fetch. */ -export const gitStatusLabelAtom = atom(undefined); +/** The latest workspace git/PR status, or `undefined` before the first fetch. */ +export const gitStatusAtom = atom(undefined); /** - * Refreshes {@link gitStatusLabelAtom} from the backend's `kqode.git.status`. + * Refreshes {@link gitStatusAtom} from the backend's `kqode.git.status`. * * Best-effort: with no backend client wired, or when the request fails, the last * known label is kept rather than blanked. The TUI triggers this on startup and * after each turn (the agent may have changed the working tree). The backend - * runs `git` and formats the label; this only stores the returned string. + * runs git/GitHub status commands and formats display segments; this only + * stores the returned status. */ export const refreshGitStatusAtom = atom(null, async (get, set) => { const client = get(backendClientAtom); @@ -19,8 +21,8 @@ export const refreshGitStatusAtom = atom(null, async (get, set) => { } try { - const label = await client.gitStatus(); - set(gitStatusLabelAtom, label ?? undefined); + const status = await client.gitStatus(); + set(gitStatusAtom, status ?? undefined); } catch { // Keep the last known label; a transient failure should not blank the cwd row. } From e703b40014791b921dc4f174cc9eb13b419f7083 Mon Sep 17 00:00:00 2001 From: Kefei Qian Date: Tue, 14 Jul 2026 20:54:03 +0800 Subject: [PATCH 04/52] feat(git): add pull_request_url to workspace status for PR hyperlink Extend the git status backend so the TUI can later render the PR label as a hyperlink. 'gh pr view' now requests 'number,url' (parsed as JSON), and WorkspaceGitStatus/GitStatusResult carry a new pullRequestUrl beside the existing pullRequestLabel. Kept the Rust<->TS protocol in lockstep (protocol.rs, backend.rs, messages.ts, client.ts, messageConnectionClient.ts) and updated the unit, integration, and protocol tests. Also removes the unused status_label() helper, which was dead code superseded by status(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d79c1bb5-e2b0-46da-be51-55e483f04bf5 --- src/backend.rs | 5 ++- src/git.rs | 2 +- src/git/status.rs | 38 +++++++++++-------- src/git/status/tests.rs | 17 +++++++-- src/protocol.rs | 7 +++- tests/git_status.rs | 5 +++ .../backend/client/messageConnectionClient.ts | 3 +- .../__tests__/messageProtocol.test.ts | 18 +++++++-- tui/src/contracts/backend/client.ts | 1 + tui/src/contracts/backend/messages.ts | 7 +++- 10 files changed, 74 insertions(+), 29 deletions(-) diff --git a/src/backend.rs b/src/backend.rs index a5bd53b4..7a6f0d60 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -167,7 +167,10 @@ fn spawn_git_status(request: Request, connection: &Connection) { id, GitStatusResult { label: status.as_ref().map(|status| status.label.clone()), - pull_request_label: status.and_then(|status| status.pull_request_label), + pull_request_label: status + .as_ref() + .and_then(|status| status.pull_request_label.clone()), + pull_request_url: status.and_then(|status| status.pull_request_url), }, ); let _ = sender.send(Message::Response(response)); diff --git a/src/git.rs b/src/git.rs index 2e70cc06..bed93651 100644 --- a/src/git.rs +++ b/src/git.rs @@ -9,4 +9,4 @@ mod command; mod status; -pub use status::{WorkspaceGitStatus, status, status_label}; +pub use status::{WorkspaceGitStatus, status}; diff --git a/src/git/status.rs b/src/git/status.rs index 15504be9..20e0fcde 100644 --- a/src/git/status.rs +++ b/src/git/status.rs @@ -1,5 +1,7 @@ use std::time::Duration; +use serde::Deserialize; + use super::command::run_stdout; /// Command used for local repository status. @@ -8,8 +10,9 @@ const GIT_COMMAND: &str = "git"; const GITHUB_COMMAND: &str = "gh"; /// Arguments that ask git for stable branch/status porcelain. const GIT_STATUS_ARGS: &[&str] = &["status", "--porcelain=v1", "--branch"]; -/// Arguments that ask GitHub CLI for the current branch's pull-request number. -const PULL_REQUEST_NUMBER_ARGS: &[&str] = &["pr", "view", "--json", "number", "--jq", ".number"]; +/// Arguments that ask GitHub CLI for the current branch's pull-request number +/// and URL as a JSON object (e.g. `{"number":3,"url":"https://…/pull/3"}`). +const PULL_REQUEST_ARGS: &[&str] = &["pr", "view", "--json", "number,url"]; /// Branch glyph prefixing every status label. const GIT_BRANCH_ICON: &str = "⎇"; /// Flag appended when the worktree has unstaged changes. @@ -35,6 +38,14 @@ const COMMAND_TIMEOUT: Duration = Duration::from_secs(2); pub struct WorkspaceGitStatus { pub label: String, pub pull_request_label: Option, + pub pull_request_url: Option, +} + +/// The current branch's GitHub pull request, as reported by `gh pr view`. +#[derive(Debug, Deserialize)] +struct PullRequest { + number: u32, + url: String, } /// Parsed porcelain status of the workspace worktree. @@ -52,28 +63,25 @@ struct GitStatus { #[must_use] pub fn status() -> Option { let parsed_status = read_status()?; + let pull_request = pull_request(); Some(WorkspaceGitStatus { label: format_label(&parsed_status), - pull_request_label: pull_request_number().map(format_pull_request_label), + pull_request_label: pull_request + .as_ref() + .map(|pull_request| format_pull_request_label(pull_request.number)), + pull_request_url: pull_request.map(|pull_request| pull_request.url), }) } -/// Returns only the formatted git status label for callers that do not need the -/// optional pull-request label. -#[must_use] -pub fn status_label() -> Option { - read_status().map(|parsed_status| format_label(&parsed_status)) -} - fn read_status() -> Option { let porcelain = run_stdout(GIT_COMMAND, GIT_STATUS_ARGS, COMMAND_TIMEOUT)?; parse_status(&porcelain) } -fn pull_request_number() -> Option { - let stdout = run_stdout(GITHUB_COMMAND, PULL_REQUEST_NUMBER_ARGS, COMMAND_TIMEOUT)?; - parse_pull_request_number(&stdout) +fn pull_request() -> Option { + let stdout = run_stdout(GITHUB_COMMAND, PULL_REQUEST_ARGS, COMMAND_TIMEOUT)?; + parse_pull_request(&stdout) } fn parse_status(porcelain: &str) -> Option { @@ -143,8 +151,8 @@ fn parse_branch_name(branch_line: &str) -> String { .to_owned() } -fn parse_pull_request_number(stdout: &str) -> Option { - stdout.trim().parse().ok() +fn parse_pull_request(stdout: &str) -> Option { + serde_json::from_str(stdout.trim()).ok() } fn format_label(status: &GitStatus) -> String { diff --git a/src/git/status/tests.rs b/src/git/status/tests.rs index 624c04fb..bf338ba1 100644 --- a/src/git/status/tests.rs +++ b/src/git/status/tests.rs @@ -1,4 +1,4 @@ -use super::{format_label, format_pull_request_label, parse_pull_request_number, parse_status}; +use super::{format_label, format_pull_request_label, parse_pull_request, parse_status}; fn parse_status_label(porcelain: &str) -> Option { parse_status(porcelain).map(|status| format_label(&status)) @@ -76,9 +76,18 @@ fn treats_untracked_entries_as_untracked_only() { } #[test] -fn formats_pull_request_label_from_number() { - assert_eq!(parse_pull_request_number("3\n"), Some(3)); - assert_eq!(format_pull_request_label(3), "#3"); +fn parses_pull_request_number_and_url_and_formats_the_label() { + let pull_request = + parse_pull_request("{\"number\":3,\"url\":\"https://github.com/o/r/pull/3\"}\n").unwrap(); + assert_eq!(pull_request.number, 3); + assert_eq!(pull_request.url, "https://github.com/o/r/pull/3"); + assert_eq!(format_pull_request_label(pull_request.number), "#3"); +} + +#[test] +fn ignores_unparseable_pull_request_output() { + assert!(parse_pull_request("").is_none()); + assert!(parse_pull_request("not json").is_none()); } #[test] diff --git a/src/protocol.rs b/src/protocol.rs index 7be6e4d8..a43635df 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -112,11 +112,14 @@ pub struct TurnErrorParams { /// Result for `kqode.git.status`: the formatted working-tree label, or `null` /// when the workspace is not a git repository (or `git` could not be queried). -/// `pullRequestLabel` is an optional GitHub PR segment such as `#3`. -/// Kept in lockstep with the TypeScript `GitStatusResult`. +/// `pullRequestLabel` is an optional GitHub PR segment such as `#3`, and +/// `pullRequestUrl` is that PR's web URL when available (so the TUI can render +/// the label as a hyperlink). Kept in lockstep with the TypeScript +/// `GitStatusResult`. #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitStatusResult { pub label: Option, pub pull_request_label: Option, + pub pull_request_url: Option, } diff --git a/tests/git_status.rs b/tests/git_status.rs index ceb6a247..fb06982b 100644 --- a/tests/git_status.rs +++ b/tests/git_status.rs @@ -40,4 +40,9 @@ fn git_status_returns_a_formatted_label_for_the_workspace() { || frames[0]["result"]["pullRequestLabel"].is_string(), "pull request label should be null or a string" ); + assert!( + frames[0]["result"]["pullRequestUrl"].is_null() + || frames[0]["result"]["pullRequestUrl"].is_string(), + "pull request url should be null or a string" + ); } diff --git a/tui/src/backend/client/messageConnectionClient.ts b/tui/src/backend/client/messageConnectionClient.ts index b10ee305..0f9af8bc 100644 --- a/tui/src/backend/client/messageConnectionClient.ts +++ b/tui/src/backend/client/messageConnectionClient.ts @@ -145,7 +145,8 @@ export function createMessageConnectionClient( ? null : { label: result.label, - pullRequestLabel: result.pullRequestLabel ?? undefined + pullRequestLabel: result.pullRequestLabel ?? undefined, + pullRequestUrl: result.pullRequestUrl ?? undefined }; } catch (error) { throw toBackendClientError('git status', error); diff --git a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts index 1da57c36..2dac42b5 100644 --- a/tui/src/backend/protocol/__tests__/messageProtocol.test.ts +++ b/tui/src/backend/protocol/__tests__/messageProtocol.test.ts @@ -198,16 +198,28 @@ describe('message protocol client', () => { describe('git status request', () => { it('resolves the formatted label the backend returns', async () => { const { client, server } = pairedConnections(); - server.onRequest(gitStatusRequest, () => ({ label: '⎇ main*', pullRequestLabel: '#3' })); + server.onRequest(gitStatusRequest, () => ({ + label: '⎇ main*', + pullRequestLabel: '#3', + pullRequestUrl: 'https://github.com/o/r/pull/3' + })); const status = await createMessageConnectionClient(client).gitStatus(); - expect(status).toEqual({ label: '⎇ main*', pullRequestLabel: '#3' }); + expect(status).toEqual({ + label: '⎇ main*', + pullRequestLabel: '#3', + pullRequestUrl: 'https://github.com/o/r/pull/3' + }); }); it('resolves null when the workspace is not a git repository', async () => { const { client, server } = pairedConnections(); - server.onRequest(gitStatusRequest, () => ({ label: null, pullRequestLabel: null })); + server.onRequest(gitStatusRequest, () => ({ + label: null, + pullRequestLabel: null, + pullRequestUrl: null + })); const status = await createMessageConnectionClient(client).gitStatus(); diff --git a/tui/src/contracts/backend/client.ts b/tui/src/contracts/backend/client.ts index 20d7a65d..55852cb0 100644 --- a/tui/src/contracts/backend/client.ts +++ b/tui/src/contracts/backend/client.ts @@ -52,6 +52,7 @@ export type StreamOutcome = export type GitStatus = { label: string; pullRequestLabel?: string; + pullRequestUrl?: string; }; /** diff --git a/tui/src/contracts/backend/messages.ts b/tui/src/contracts/backend/messages.ts index 6b351682..17eadbb1 100644 --- a/tui/src/contracts/backend/messages.ts +++ b/tui/src/contracts/backend/messages.ts @@ -104,10 +104,13 @@ export type TurnErrorParams = { * Result for `kqode.git.status`: the formatted working-tree label (e.g. * `⎇ main*`), or `null` when the workspace is not a git repository or `git` * could not be queried. `pullRequestLabel` is an optional GitHub PR segment - * such as `#3`. The Rust backend owns parsing and formatting (`GitStatusResult` - * in `src/protocol.rs`); keep the two shapes in lockstep. + * such as `#3`, and `pullRequestUrl` is that PR's web URL when available (so the + * TUI can render the label as a hyperlink). The Rust backend owns parsing and + * formatting (`GitStatusResult` in `src/protocol.rs`); keep the two shapes in + * lockstep. */ export type GitStatusResult = { label: string | null; pullRequestLabel: string | null; + pullRequestUrl: string | null; }; From fb30f9b1584ec33c53e651668532bcb14587da92 Mon Sep 17 00:00:00 2001 From: Kefei Qian Date: Tue, 14 Jul 2026 22:32:25 +0800 Subject: [PATCH 05/52] feat(tui): render the PR label as a dotted-underline OSC 8 hyperlink Decorate the cwd row's pull-request segment (e.g. #3) so supporting terminals show it as a clickable OSC 8 hyperlink with a dotted-underline affordance. New pure builders in libs/terminal/hyperlink.ts wrap the label; cwdLine.ts gains renderCwdLine (display) beside formatCwdLine (plain), and CwdLine.tsx renders the decorated string. The label is decorated only when the status carries a pull_request_url, so a bare label never implies a dead link. formatCwdLine stays plain and remains the source of truth for countCwdRows' width math, so the zero-width escapes cannot shift the cursor-sensitive bottom rows. Note: Ink's slice-ansi drops a lone 4:3 (dotted) underline reset, so dottedUnderline leads with a standard 4 underline to keep the 24m reset paired; 4:3 then upgrades to dotted where supported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d79c1bb5-e2b0-46da-be51-55e483f04bf5 --- .../__tests__/components/HomeScreen.test.tsx | 20 ++++++++ tui/src/components/CwdLine.tsx | 4 +- .../libs/terminal/__tests__/hyperlink.test.ts | 18 +++++++ tui/src/libs/terminal/hyperlink.ts | 41 ++++++++++++++++ tui/src/libs/tui/__tests__/cwdLine.test.ts | 47 +++++++++++++++++++ tui/src/libs/tui/cwdLine.ts | 43 ++++++++++++++++- 6 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 tui/src/libs/terminal/__tests__/hyperlink.test.ts create mode 100644 tui/src/libs/terminal/hyperlink.ts create mode 100644 tui/src/libs/tui/__tests__/cwdLine.test.ts diff --git a/tui/src/__tests__/components/HomeScreen.test.tsx b/tui/src/__tests__/components/HomeScreen.test.tsx index 65450a95..7cc6f520 100644 --- a/tui/src/__tests__/components/HomeScreen.test.tsx +++ b/tui/src/__tests__/components/HomeScreen.test.tsx @@ -232,6 +232,26 @@ describe('HomeScreen', () => { expect(output).not.toContain('cwd '); }); + it('renders the PR label as a dotted-underline OSC 8 hyperlink when a PR url is present', () => { + const { lastFrame } = renderHomeScreen({ + gitStatus: { + label: '⎇ main*', + pullRequestLabel: '#3', + pullRequestUrl: 'https://github.com/o/r/pull/3' + }, + columns: 100, + rows: 20 + }); + + const output = lastFrame() ?? ''; + + // OSC 8 hyperlink pointing at the PR, wrapping a #3 that carries a primed + // dotted underline (4m then 4:3m) with its 24m reset intact. + expect(output).toContain( + '\u001B]8;;https://github.com/o/r/pull/3\u0007\u001B[4m\u001B[4:3m#3\u001B[24m' + ); + }); + it('soft-wraps a long cwd without truncating it', () => { const longWorkspace = path.join( workspaceCwd, diff --git a/tui/src/components/CwdLine.tsx b/tui/src/components/CwdLine.tsx index ae51d747..3d54b55a 100644 --- a/tui/src/components/CwdLine.tsx +++ b/tui/src/components/CwdLine.tsx @@ -1,6 +1,6 @@ import { Box, Text } from 'ink'; import { useAtomValue } from 'jotai'; -import { formatCwdLine } from '@libs/tui/cwdLine.ts'; +import { renderCwdLine } from '@libs/tui/cwdLine.ts'; import { gitStatusAtom } from '@state/ui/index.ts'; import { workspaceCwdAtom } from '@state/global/index.ts'; import { theme } from '@theme/themeConfig.ts'; @@ -11,7 +11,7 @@ export function CwdLine() { return ( - {formatCwdLine(workspaceCwd, gitStatus)} + {renderCwdLine(workspaceCwd, gitStatus)} ); } diff --git a/tui/src/libs/terminal/__tests__/hyperlink.test.ts b/tui/src/libs/terminal/__tests__/hyperlink.test.ts new file mode 100644 index 00000000..5af5913e --- /dev/null +++ b/tui/src/libs/terminal/__tests__/hyperlink.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { dottedUnderline, hyperlink } from '@libs/terminal/hyperlink.ts'; + +describe('hyperlink', () => { + it('wraps text in an OSC 8 hyperlink pointing at the url', () => { + expect(hyperlink('#3', 'https://github.com/o/r/pull/3')).toBe( + '\u001B]8;;https://github.com/o/r/pull/3\u0007#3\u001B]8;;\u0007' + ); + }); +}); + +describe('dottedUnderline', () => { + it('primes a standard underline before the dotted refinement so the reset survives', () => { + // Leading `4m` (plain underline) keeps Ink's slice-ansi tracking the state so + // the `24m` reset is preserved; `4:3m` upgrades to dotted where supported. + expect(dottedUnderline('#3')).toBe('\u001B[4m\u001B[4:3m#3\u001B[24m'); + }); +}); diff --git a/tui/src/libs/terminal/hyperlink.ts b/tui/src/libs/terminal/hyperlink.ts new file mode 100644 index 00000000..4fe92bc7 --- /dev/null +++ b/tui/src/libs/terminal/hyperlink.ts @@ -0,0 +1,41 @@ +// OSC 8 hyperlink: `ESC ] 8 ; ; BEL ESC ] 8 ; ; BEL`. +// The params field is left empty; BEL (`\u0007`) terminates each OSC, matching +// the terminal's other OSC usage (see windowTitle.ts) and staying broadly +// compatible. +const OSC_HYPERLINK_INTRODUCER = '\u001B]8;;'; +const OSC_TERMINATOR = '\u0007'; + +/** + * Wraps `text` in an OSC 8 hyperlink pointing at `url`. + * + * Terminals that support OSC 8 (Windows Terminal, WezTerm, iTerm2, VTE) turn the + * enclosed text into a clickable link; terminals that don't simply render `text` + * unchanged. The sequence adds no printed columns, so callers can keep measuring + * width on the undecorated text. + */ +export function hyperlink(text: string, url: string): string { + return `${OSC_HYPERLINK_INTRODUCER}${url}${OSC_TERMINATOR}${text}${OSC_HYPERLINK_INTRODUCER}${OSC_TERMINATOR}`; +} + +// SGR `4` enables a standard underline; `4:3` upgrades it to a dotted style on +// terminals that support the sub-parameter. Leading with plain `4` matters: +// Ink's slice-ansi tracks the standard underline state and so preserves the +// matching `24` (underline off) reset, whereas a lone `4:3` gets its reset +// dropped and the underline bleeds past the label. +const UNDERLINE_ON = '\u001B[4m'; +const DOTTED_UNDERLINE_ON = '\u001B[4:3m'; +const UNDERLINE_OFF = '\u001B[24m'; + +/** + * Wraps `text` in a dotted underline as an always-visible "this is clickable" + * affordance. + * + * Emits a standard underline (`4`) followed by the dotted refinement (`4:3`): + * capable terminals (Windows Terminal, WezTerm, kitty) render dotted, others + * fall back to a straight underline, and either way the `24` reset stays paired + * so the underline never bleeds past `text`. Like {@link hyperlink}, the escapes + * add no printed columns. + */ +export function dottedUnderline(text: string): string { + return `${UNDERLINE_ON}${DOTTED_UNDERLINE_ON}${text}${UNDERLINE_OFF}`; +} diff --git a/tui/src/libs/tui/__tests__/cwdLine.test.ts b/tui/src/libs/tui/__tests__/cwdLine.test.ts new file mode 100644 index 00000000..3312c258 --- /dev/null +++ b/tui/src/libs/tui/__tests__/cwdLine.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { formatCwdLine, renderCwdLine } from '@libs/tui/cwdLine.ts'; + +const statusWithPr = { + label: '⎇ main*', + pullRequestLabel: '#3', + pullRequestUrl: 'https://github.com/o/r/pull/3' +}; + +/** Removes OSC 8 and SGR escapes so decorated text can be compared to plain. */ +function stripEscapes(text: string): string { + return text.replace(/\u001B\]8;;[^\u0007]*\u0007/g, '').replace(/\u001B\[[0-9:;]*m/g, ''); +} + +describe('formatCwdLine (plain, drives width math)', () => { + it('joins the cwd, git, and PR segments as plain text', () => { + expect(formatCwdLine('/tmp/x', statusWithPr)).toContain(' [⎇ main*] [#3]'); + }); + + it('never contains escape sequences, even when a PR url is present', () => { + expect(formatCwdLine('/tmp/x', statusWithPr)).not.toContain('\u001B'); + }); +}); + +describe('renderCwdLine (decorated, for display)', () => { + it('wraps the PR label in a dotted underline + OSC 8 hyperlink when a url is present', () => { + expect(renderCwdLine('/tmp/x', statusWithPr)).toContain( + ' [\u001B]8;;https://github.com/o/r/pull/3\u0007\u001B[4m\u001B[4:3m#3\u001B[24m\u001B]8;;\u0007]' + ); + }); + + it('leaves the PR label plain when there is no url, so the affordance is never a dead link', () => { + const line = renderCwdLine('/tmp/x', { label: '⎇ main', pullRequestLabel: '#3' }); + expect(line).toContain(' [#3]'); + expect(line).not.toContain('\u001B'); + }); + + it('adds only zero-width escapes, so its visible text equals formatCwdLine', () => { + expect(stripEscapes(renderCwdLine('/tmp/x', statusWithPr))).toBe( + formatCwdLine('/tmp/x', statusWithPr) + ); + }); + + it('matches formatCwdLine exactly when there is no git status', () => { + expect(renderCwdLine('/tmp/x', undefined)).toBe(formatCwdLine('/tmp/x', undefined)); + }); +}); diff --git a/tui/src/libs/tui/cwdLine.ts b/tui/src/libs/tui/cwdLine.ts index 31f415d1..2552d6f2 100644 --- a/tui/src/libs/tui/cwdLine.ts +++ b/tui/src/libs/tui/cwdLine.ts @@ -1,13 +1,52 @@ import os from 'node:os'; import path from 'node:path'; import type { GitStatus } from '@contracts/backend/index.ts'; +import { dottedUnderline, hyperlink } from '@libs/terminal/hyperlink.ts'; -export function formatCwdLine(workspaceCwd: string, gitStatus?: GitStatus): string { +/** The cwd + git-label prefix shared by the plain and decorated renderings. */ +function cwdAndGitPrefix(workspaceCwd: string, gitStatus?: GitStatus): string { const cwdSegment = formatDisplayCwd(workspaceCwd); const gitSegment = gitStatus === undefined ? '' : ` [${gitStatus.label}]`; + return `${cwdSegment}${gitSegment}`; +} + +/** + * The cwd row as plain text (no escape sequences), e.g. `~/proj [⎇ main*] [#3]`. + * + * Width and wrap math ({@link countCwdRows}) measure this string; + * {@link renderCwdLine} produces the display string, which has the same visible + * characters plus zero-width decoration, so both agree on the row's width. + */ +export function formatCwdLine(workspaceCwd: string, gitStatus?: GitStatus): string { const pullRequestSegment = gitStatus?.pullRequestLabel === undefined ? '' : ` [${gitStatus.pullRequestLabel}]`; - return `${cwdSegment}${gitSegment}${pullRequestSegment}`; + return `${cwdAndGitPrefix(workspaceCwd, gitStatus)}${pullRequestSegment}`; +} + +/** + * The cwd row for display: identical visible text to {@link formatCwdLine}, but + * when the status carries a pull-request URL the PR label (e.g. `#3`) is wrapped + * in a dotted underline and an OSC 8 hyperlink so supporting terminals render it + * as a clickable link. Only zero-width escapes are added, so {@link formatCwdLine} + * stays the source of truth for width. + */ +export function renderCwdLine(workspaceCwd: string, gitStatus?: GitStatus): string { + return `${cwdAndGitPrefix(workspaceCwd, gitStatus)}${renderPullRequestSegment(gitStatus)}`; +} + +/** + * The ` [#3]` pull-request segment for display. The label is decorated (dotted + * underline + OSC 8 link) only when a URL is present, so a bare label never + * implies a link that cannot be opened. + */ +function renderPullRequestSegment(gitStatus?: GitStatus): string { + const label = gitStatus?.pullRequestLabel; + if (label === undefined) { + return ''; + } + const url = gitStatus?.pullRequestUrl; + const rendered = url === undefined ? label : hyperlink(dottedUnderline(label), url); + return ` [${rendered}]`; } export function countCwdRows( From 5ac7e1c747cf50ade18dcfaad9e67a91cf434438 Mon Sep 17 00:00:00 2001 From: Kefei Qian Date: Tue, 14 Jul 2026 22:54:53 +0800 Subject: [PATCH 06/52] feat(tui): open the PR on a plain click of the #3 label The TUI owns the mouse (SGR tracking), so a plain left click on the cwd line's PR label now opens the pull request in the browser. resolvePullRequestClickTarget hit-tests the label span using the same hard-wrap model as countCwdRows (so wrapped rows map correctly), and openExternalUrl launches the OS opener (cmd start / open / xdg-open) as a detached argv process, validating the URL is http(s) so a hostile URL cannot inject a command. Wired via a focused usePullRequestClick hook to keep HomeScreenView under the ~200-line guideline. pullRequestLabelOffset (in cwdLine.ts) locates the label; the label is only actionable when a pull_request_url is present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d79c1bb5-e2b0-46da-be51-55e483f04bf5 --- .../components/HomeScreen/HomeScreenView.tsx | 15 ++- .../__tests__/prLabelClick.test.tsx | 81 ++++++++++++++++ .../HomeScreen/usePullRequestClick.ts | 39 ++++++++ .../libs/os/__tests__/openExternalUrl.test.ts | 82 ++++++++++++++++ tui/src/libs/os/openExternalUrl.ts | 75 +++++++++++++++ tui/src/libs/tui/__tests__/cwdLine.test.ts | 16 +++- .../tui/__tests__/pullRequestClick.test.ts | 93 +++++++++++++++++++ tui/src/libs/tui/cwdLine.ts | 17 ++++ tui/src/libs/tui/pullRequestClick.ts | 53 +++++++++++ 9 files changed, 466 insertions(+), 5 deletions(-) create mode 100644 tui/src/components/HomeScreen/__tests__/prLabelClick.test.tsx create mode 100644 tui/src/components/HomeScreen/usePullRequestClick.ts create mode 100644 tui/src/libs/os/__tests__/openExternalUrl.test.ts create mode 100644 tui/src/libs/os/openExternalUrl.ts create mode 100644 tui/src/libs/tui/__tests__/pullRequestClick.test.ts create mode 100644 tui/src/libs/tui/pullRequestClick.ts diff --git a/tui/src/components/HomeScreen/HomeScreenView.tsx b/tui/src/components/HomeScreen/HomeScreenView.tsx index b8c2ab0c..c0bd2e8c 100644 --- a/tui/src/components/HomeScreen/HomeScreenView.tsx +++ b/tui/src/components/HomeScreen/HomeScreenView.tsx @@ -15,6 +15,7 @@ import { } from '@libs/terminal/mouse.ts'; import { resolveWheelTarget } from '@components/HomeScreen/wheelRouting.ts'; import { useCaretScrollSuppression } from '@components/HomeScreen/useCaretScrollSuppression.ts'; +import { usePullRequestClick } from '@components/HomeScreen/usePullRequestClick.ts'; import { resolveClickResult } from '@libs/composer/composerWindow.ts'; import { BODY_CWD_GAP_ROWS } from '@libs/tui/layout.ts'; import { @@ -49,6 +50,7 @@ export function HomeScreenView() { const scrollBodyByRows = useSetAtom(scrollBodyByRowsAtom); const scrollComposerByRows = useSetAtom(scrollComposerByRowsAtom); const notifyScroll = useCaretScrollSuppression(); + const handlePullRequestClick = usePullRequestClick(); const store = useStore(); useEffect(() => { @@ -86,10 +88,15 @@ export function HomeScreenView() { const click = parseMouseClickEvent(input); if (click !== null) { - // Map the click to a cursor index + the scroll offset that keeps the - // visible window fixed (clicking repositions the caret without scrolling). - // Text rows start one row below composerTop (the top half-line cap); the - // prompt prefix offsets columns. + // A plain click on the PR label (#3) opens the pull request; otherwise the + // click maps to a composer cursor index + the scroll offset that keeps the + // visible window fixed (repositioning the caret without scrolling). Text + // rows start one row below composerTop (the top half-line cap); the prompt + // prefix offsets columns. + if (handlePullRequestClick(click)) { + return; + } + const composerState = store.get(composerStateAtom); const result = resolveClickResult({ text: composerState.text, diff --git a/tui/src/components/HomeScreen/__tests__/prLabelClick.test.tsx b/tui/src/components/HomeScreen/__tests__/prLabelClick.test.tsx new file mode 100644 index 00000000..4a5dcc4e --- /dev/null +++ b/tui/src/components/HomeScreen/__tests__/prLabelClick.test.tsx @@ -0,0 +1,81 @@ +import { createStore } from 'jotai'; +import os from 'node:os'; +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@libs/os/openExternalUrl.ts', () => ({ + openExternalUrl: vi.fn(() => true) +})); + +import { App } from '@/App.tsx'; +import type { GitStatus } from '@contracts/backend/index.ts'; +import { openExternalUrl } from '@libs/os/openExternalUrl.ts'; +import { pullRequestLabelOffset } from '@libs/tui/cwdLine.ts'; +import { flushInput } from '@test/flushInput.ts'; +import { renderWithJotai } from '@test/renderWithJotai.tsx'; +import { + columnsTestOverrideAtom, + composerTopAtom, + cwdRowsAtom, + gitStatusAtom, + rowsTestOverrideAtom +} from '@state/ui/index.ts'; +import { workspaceCwdAtom } from '@state/global/index.ts'; + +const workspaceCwd = path.join(os.homedir(), 'Projects', 'KQode'); +const url = 'https://github.com/o/r/pull/3'; +const gitStatus: GitStatus = { label: '⎇ main*', pullRequestLabel: '#3', pullRequestUrl: url }; + +/** Renders the App at a wide, single-row-cwd size and returns the click seam. */ +function renderApp() { + const store = createStore(); + store.set(workspaceCwdAtom, workspaceCwd); + store.set(gitStatusAtom, gitStatus); + store.set(columnsTestOverrideAtom, 100); + store.set(rowsTestOverrideAtom, 20); + const instance = renderWithJotai(, store); + return { store, ...instance }; +} + +/** SGR left-button press at a 1-based row/column. */ +function clickAt(column: number, row: number): string { + return `\u001B[<0;${column};${row}M`; +} + +describe('pull-request label click wiring', () => { + beforeEach(() => { + vi.mocked(openExternalUrl).mockClear(); + }); + + it('opens the PR url on a plain click on the #3 label', async () => { + const { store, stdin } = renderApp(); + await flushInput(); + + const composerTop = store.get(composerTopAtom); + const cwdRows = store.get(cwdRowsAtom); + const labelStart = pullRequestLabelOffset(workspaceCwd, gitStatus) ?? -1; + // Single cwd row at rowOffset 0 (1-based row); first character of #3. + const row = composerTop - cwdRows + 1; + const column = labelStart + 1; + + stdin.write(clickAt(column, row)); + await flushInput(); + + expect(openExternalUrl).toHaveBeenCalledWith(url); + }); + + it('does not open when the click lands on the bracket left of the label', async () => { + const { store, stdin } = renderApp(); + await flushInput(); + + const composerTop = store.get(composerTopAtom); + const cwdRows = store.get(cwdRowsAtom); + const labelStart = pullRequestLabelOffset(workspaceCwd, gitStatus) ?? -1; + const row = composerTop - cwdRows + 1; + + stdin.write(clickAt(labelStart, row)); // column = labelStart → offset labelStart-1 (the '[') + await flushInput(); + + expect(openExternalUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/tui/src/components/HomeScreen/usePullRequestClick.ts b/tui/src/components/HomeScreen/usePullRequestClick.ts new file mode 100644 index 00000000..6af8e941 --- /dev/null +++ b/tui/src/components/HomeScreen/usePullRequestClick.ts @@ -0,0 +1,39 @@ +import { useAtomValue, useStore } from 'jotai'; +import type { MouseClickEvent } from '@libs/terminal/mouse.ts'; +import { openExternalUrl } from '@libs/os/openExternalUrl.ts'; +import { resolvePullRequestClickTarget } from '@libs/tui/pullRequestClick.ts'; +import { columnsAtom, composerTopAtom, cwdRowsAtom, gitStatusAtom } from '@state/ui/index.ts'; +import { workspaceCwdAtom } from '@state/global/index.ts'; + +/** + * Returns a handler that opens the pull request when a left click lands on the + * cwd line's `#3` label, reporting whether it consumed the click. + * + * The app owns the mouse (SGR tracking), so the label span is hit-tested here on + * a plain single click rather than relying on the terminal's modifier+click for + * the OSC 8 link. `columns`/`composerTop` are read reactively; the click-time + * values (`cwdRows`, `workspaceCwd`, `gitStatus`) are read from the store when + * the click fires. + */ +export function usePullRequestClick(): (click: MouseClickEvent) => boolean { + const store = useStore(); + const columns = useAtomValue(columnsAtom); + const composerTop = useAtomValue(composerTopAtom); + + return (click: MouseClickEvent): boolean => { + const url = resolvePullRequestClickTarget({ + clickRow: click.row, + clickColumn: click.column, + composerTop, + cwdRows: store.get(cwdRowsAtom), + columns, + workspaceCwd: store.get(workspaceCwdAtom), + gitStatus: store.get(gitStatusAtom) + }); + if (url === undefined) { + return false; + } + openExternalUrl(url); + return true; + }; +} diff --git a/tui/src/libs/os/__tests__/openExternalUrl.test.ts b/tui/src/libs/os/__tests__/openExternalUrl.test.ts new file mode 100644 index 00000000..738e2963 --- /dev/null +++ b/tui/src/libs/os/__tests__/openExternalUrl.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + isOpenableUrl, + openExternalUrl, + resolveOpenCommand +} from '@libs/os/openExternalUrl.ts'; + +const url = 'https://github.com/o/r/pull/3'; + +describe('isOpenableUrl', () => { + it('accepts http and https urls', () => { + expect(isOpenableUrl(url)).toBe(true); + expect(isOpenableUrl('http://example.com')).toBe(true); + }); + + it('rejects non-http(s) schemes and malformed urls', () => { + expect(isOpenableUrl('file:///etc/passwd')).toBe(false); + expect(isOpenableUrl('javascript:alert(1)')).toBe(false); + expect(isOpenableUrl('not a url')).toBe(false); + expect(isOpenableUrl('')).toBe(false); + }); +}); + +describe('resolveOpenCommand', () => { + it('uses cmd start with an empty title on Windows, url as its own arg', () => { + expect(resolveOpenCommand(url, 'win32')).toEqual({ + command: 'cmd', + args: ['/c', 'start', '', url] + }); + }); + + it('uses open on macOS', () => { + expect(resolveOpenCommand(url, 'darwin')).toEqual({ command: 'open', args: [url] }); + }); + + it('uses xdg-open on Linux', () => { + expect(resolveOpenCommand(url, 'linux')).toEqual({ command: 'xdg-open', args: [url] }); + }); + + it('returns null for a non-openable url so no process is launched', () => { + expect(resolveOpenCommand('file:///x', 'linux')).toBeNull(); + }); +}); + +describe('openExternalUrl', () => { + it('spawns the resolved opener detached and unref’d, reporting success', () => { + const unref = vi.fn(); + const spawnProcess = vi.fn( + (_command: string, _args: string[], _options: { stdio: 'ignore'; detached: true }) => ({ + unref + }) + ); + + expect(openExternalUrl(url, 'linux', spawnProcess)).toBe(true); + expect(spawnProcess).toHaveBeenCalledWith('xdg-open', [url], { + stdio: 'ignore', + detached: true + }); + expect(unref).toHaveBeenCalledOnce(); + }); + + it('does not spawn for a non-openable url', () => { + const spawnProcess = vi.fn( + (_command: string, _args: string[], _options: { stdio: 'ignore'; detached: true }) => ({ + unref: vi.fn() + }) + ); + + expect(openExternalUrl('file:///x', 'linux', spawnProcess)).toBe(false); + expect(spawnProcess).not.toHaveBeenCalled(); + }); + + it('returns false instead of throwing when the spawn fails', () => { + const spawnProcess = vi.fn( + (_command: string, _args: string[], _options: { stdio: 'ignore'; detached: true }) => { + throw new Error('spawn failed'); + } + ); + + expect(openExternalUrl(url, 'linux', spawnProcess)).toBe(false); + }); +}); diff --git a/tui/src/libs/os/openExternalUrl.ts b/tui/src/libs/os/openExternalUrl.ts new file mode 100644 index 00000000..2490e7a2 --- /dev/null +++ b/tui/src/libs/os/openExternalUrl.ts @@ -0,0 +1,75 @@ +import { spawn } from 'node:child_process'; + +const OPENABLE_PROTOCOLS = new Set(['http:', 'https:']); + +/** Whether `url` is a well-formed http(s) URL safe to hand to the OS opener. */ +export function isOpenableUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + return OPENABLE_PROTOCOLS.has(parsed.protocol); +} + +/** A command and its argument vector for launching the platform URL opener. */ +export type OpenCommand = { command: string; args: string[] }; + +/** + * The platform command + argv that opens `url` in the default browser, or `null` + * when `url` is not an openable http(s) URL. + * + * Pure and separated from the spawn so the mapping is unit-testable. The argv + * form (never a shell string) keeps the URL from being reinterpreted as extra + * arguments; combined with the http(s) check it blocks command injection from a + * hostile URL. + */ +export function resolveOpenCommand( + url: string, + platform: NodeJS.Platform = process.platform +): OpenCommand | null { + if (!isOpenableUrl(url)) { + return null; + } + if (platform === 'win32') { + // `start` treats its first quoted argument as the window title, so pass an + // empty title before the url. + return { command: 'cmd', args: ['/c', 'start', '', url] }; + } + if (platform === 'darwin') { + return { command: 'open', args: [url] }; + } + return { command: 'xdg-open', args: [url] }; +} + +/** Minimal spawn seam so tests can observe the launch without a real process. */ +export type ProcessSpawner = ( + command: string, + args: string[], + options: { stdio: 'ignore'; detached: true } +) => { unref: () => void }; + +/** + * Opens `url` in the user's default browser as a detached, unref'd process. + * + * Returns whether a launch was attempted: `false` for a non-openable URL or a + * spawn failure. Best-effort by design — KQode owns the terminal input loop, so + * a failed open must never throw into it. + */ +export function openExternalUrl( + url: string, + platform: NodeJS.Platform = process.platform, + spawnProcess: ProcessSpawner = (command, args, options) => spawn(command, args, options) +): boolean { + const resolved = resolveOpenCommand(url, platform); + if (resolved === null) { + return false; + } + try { + spawnProcess(resolved.command, resolved.args, { stdio: 'ignore', detached: true }).unref(); + return true; + } catch { + return false; + } +} diff --git a/tui/src/libs/tui/__tests__/cwdLine.test.ts b/tui/src/libs/tui/__tests__/cwdLine.test.ts index 3312c258..b8b09e3b 100644 --- a/tui/src/libs/tui/__tests__/cwdLine.test.ts +++ b/tui/src/libs/tui/__tests__/cwdLine.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatCwdLine, renderCwdLine } from '@libs/tui/cwdLine.ts'; +import { formatCwdLine, pullRequestLabelOffset, renderCwdLine } from '@libs/tui/cwdLine.ts'; const statusWithPr = { label: '⎇ main*', @@ -45,3 +45,17 @@ describe('renderCwdLine (decorated, for display)', () => { expect(renderCwdLine('/tmp/x', undefined)).toBe(formatCwdLine('/tmp/x', undefined)); }); }); + +describe('pullRequestLabelOffset', () => { + it('points at the first character of the PR label within the plain line', () => { + const offset = pullRequestLabelOffset('/tmp/x', statusWithPr); + const line = formatCwdLine('/tmp/x', statusWithPr); + expect(offset).toBeGreaterThan(0); + // The label runs to the end of the line, just before the closing bracket. + expect(line.slice(offset)).toBe('#3]'); + }); + + it('is undefined when there is no PR label', () => { + expect(pullRequestLabelOffset('/tmp/x', { label: '⎇ main' })).toBeUndefined(); + }); +}); diff --git a/tui/src/libs/tui/__tests__/pullRequestClick.test.ts b/tui/src/libs/tui/__tests__/pullRequestClick.test.ts new file mode 100644 index 00000000..b7ed0049 --- /dev/null +++ b/tui/src/libs/tui/__tests__/pullRequestClick.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import type { GitStatus } from '@contracts/backend/index.ts'; +import { pullRequestLabelOffset } from '@libs/tui/cwdLine.ts'; +import { resolvePullRequestClickTarget } from '@libs/tui/pullRequestClick.ts'; + +const url = 'https://github.com/o/r/pull/3'; +const workspaceCwd = '/tmp/proj'; +const gitStatus: GitStatus = { label: '⎇ main', pullRequestLabel: '#3', pullRequestUrl: url }; + +// The cwd line renders one row directly above the composer at a wide width, so +// its 0-based row is composerTop - 1 (1-based SGR row = composerTop). +const composerTop = 12; +const singleRow = { composerTop, cwdRows: 1, columns: 200, workspaceCwd, gitStatus }; +const labelStart = pullRequestLabelOffset(workspaceCwd, gitStatus) ?? -1; + +describe('resolvePullRequestClickTarget', () => { + it('returns the url for a click on the first character of the label', () => { + expect( + resolvePullRequestClickTarget({ ...singleRow, clickRow: composerTop, clickColumn: labelStart + 1 }) + ).toBe(url); + }); + + it('returns the url for a click on the last character of the label', () => { + expect( + resolvePullRequestClickTarget({ + ...singleRow, + clickRow: composerTop, + clickColumn: labelStart + gitStatus.pullRequestLabel!.length + }) + ).toBe(url); + }); + + it('misses the opening bracket just before the label', () => { + expect( + resolvePullRequestClickTarget({ ...singleRow, clickRow: composerTop, clickColumn: labelStart }) + ).toBeUndefined(); + }); + + it('misses the closing bracket just after the label', () => { + expect( + resolvePullRequestClickTarget({ + ...singleRow, + clickRow: composerTop, + clickColumn: labelStart + gitStatus.pullRequestLabel!.length + 1 + }) + ).toBeUndefined(); + }); + + it('misses a click on the composer row below the cwd line', () => { + expect( + resolvePullRequestClickTarget({ ...singleRow, clickRow: composerTop + 1, clickColumn: labelStart + 1 }) + ).toBeUndefined(); + }); + + it('returns undefined when the status has no PR url', () => { + expect( + resolvePullRequestClickTarget({ + ...singleRow, + gitStatus: { label: '⎇ main', pullRequestLabel: '#3' }, + clickRow: composerTop, + clickColumn: labelStart + 1 + }) + ).toBeUndefined(); + }); + + it('returns undefined when the cwd line is hidden (command palette open, cwdRows 0)', () => { + expect( + resolvePullRequestClickTarget({ ...singleRow, cwdRows: 0, clickRow: composerTop, clickColumn: labelStart + 1 }) + ).toBeUndefined(); + }); + + it('maps a click onto a wrapped row using the hard-wrap model', () => { + // Force the label onto the second visual row by making each row narrower + // than the label's offset, then derive the click from that wrap model. + const columns = Math.max(1, labelStart - 1); + const rowOffset = Math.floor(labelStart / columns); + const columnInRow = labelStart % columns; + const cwdRows = rowOffset + 1; + const clickRow = composerTop - cwdRows + rowOffset + 1; + + expect( + resolvePullRequestClickTarget({ + composerTop, + cwdRows, + columns, + workspaceCwd, + gitStatus, + clickRow, + clickColumn: columnInRow + 1 + }) + ).toBe(url); + }); +}); diff --git a/tui/src/libs/tui/cwdLine.ts b/tui/src/libs/tui/cwdLine.ts index 2552d6f2..645650af 100644 --- a/tui/src/libs/tui/cwdLine.ts +++ b/tui/src/libs/tui/cwdLine.ts @@ -49,6 +49,23 @@ function renderPullRequestSegment(gitStatus?: GitStatus): string { return ` [${rendered}]`; } +/** + * The visible character offset at which the pull-request label (e.g. `#3`) + * starts within {@link formatCwdLine}, or `undefined` when no PR label is shown. + * + * The click router uses it to map a pointer position back to the label's span. + * The PR segment is ` [ ); diff --git a/tui/src/components/PromptComposer/cursorPosition.ts b/tui/src/components/PromptComposer/cursorPosition.ts index eadac40b..a03bfb33 100644 --- a/tui/src/components/PromptComposer/cursorPosition.ts +++ b/tui/src/components/PromptComposer/cursorPosition.ts @@ -4,6 +4,7 @@ import { PROMPT_PREFIX } from '@constants/ui.ts'; import { clamp } from '@libs/math/clamp.ts'; +import { displayWidth } from '@libs/text/displayWidth.ts'; export function resolveComposerCursorPosition( visibleText: string, @@ -33,7 +34,7 @@ function cursorPositionForVisibleText( const lines = textBeforeCursor.split('\n'); const lastLine = lines.at(-1) ?? ''; return { - x: Math.min(lastLine.length, columns), + x: Math.min(displayWidth(lastLine), columns), y: lines.length - 1 }; } diff --git a/tui/src/components/PromptComposer/input/handleCursorMove.ts b/tui/src/components/PromptComposer/input/handleCursorMove.ts index 3c9f8109..dbbe24a5 100644 --- a/tui/src/components/PromptComposer/input/handleCursorMove.ts +++ b/tui/src/components/PromptComposer/input/handleCursorMove.ts @@ -9,7 +9,7 @@ import { } from '@state/ui/composer/index.ts'; /** - * Left/Right move the composer cursor by one code point; Up/Down move between + * Left/Right move the composer cursor by one grapheme cluster; Up/Down move between * visual (wrapped) lines. The slash menu's Up/Down is handled earlier in the * dispatcher (handleMenuKey), so these only fire when the menu is closed. */ diff --git a/tui/src/libs/composer/__tests__/composerWindow.test.ts b/tui/src/libs/composer/__tests__/composerWindow.test.ts index 0a1f6ff8..1f4ee43d 100644 --- a/tui/src/libs/composer/__tests__/composerWindow.test.ts +++ b/tui/src/libs/composer/__tests__/composerWindow.test.ts @@ -96,6 +96,10 @@ describe('resolveVerticalCursorIndex', () => { expect(resolveVerticalCursorIndex('abcdefghij', 4, 2, 'down')).toBe(6); expect(resolveVerticalCursorIndex('abcdefghij', 4, 6, 'up')).toBe(2); }); + + it('preserves the visual column across wide glyphs', () => { + expect(resolveVerticalCursorIndex('界a\n123', 40, 2, 'down')).toBe(6); + }); }); describe('resolveClickResult', () => { @@ -107,6 +111,25 @@ describe('resolveClickResult', () => { expect(resolveClickResult({ ...base, visibleRow: 1, column: 2 })?.index).toBe(6); }); + it('maps clicks inside wide and joined graphemes to safe boundaries', () => { + const wide = { text: '界a', columns: 40, maxVisibleLines: 1, cursorIndex: 2, offset: 0 }; + expect(resolveClickResult({ ...wide, visibleRow: 0, column: 1 })?.index).toBe(0); + expect(resolveClickResult({ ...wide, visibleRow: 0, column: 2 })?.index).toBe(1); + + const emoji = '👨‍👩‍👧‍👦x'; + expect( + resolveClickResult({ + text: emoji, + columns: 40, + maxVisibleLines: 1, + cursorIndex: emoji.length, + offset: 0, + visibleRow: 0, + column: 1 + })?.index + ).toBe(0); + }); + it('clamps the column to the clicked row length (and floors at 0)', () => { expect(resolveClickResult({ ...base, visibleRow: 2, column: 99 })?.index).toBe(11); expect(resolveClickResult({ ...base, visibleRow: 0, column: -5 })?.index).toBe(0); diff --git a/tui/src/libs/composer/__tests__/wrapPromptText.test.ts b/tui/src/libs/composer/__tests__/wrapPromptText.test.ts index 6893c5cb..be827963 100644 --- a/tui/src/libs/composer/__tests__/wrapPromptText.test.ts +++ b/tui/src/libs/composer/__tests__/wrapPromptText.test.ts @@ -18,6 +18,18 @@ describe('wrapPromptText', () => { expect(countWrappedPromptRows('abcdefghij', 4)).toBe(3); }); + it('wraps CJK text by terminal display columns', () => { + expect(wrapPromptText('界界界', 4).map((row) => row.text)).toEqual(['界界', '界']); + }); + + it('does not split combining or joined emoji graphemes', () => { + expect(wrapPromptText('e\u0301e\u0301', 1).map((row) => row.text)).toEqual([ + 'e\u0301', + 'e\u0301' + ]); + expect(wrapPromptText('👨‍👩‍👧‍👦x', 2).map((row) => row.text)).toEqual(['👨‍👩‍👧‍👦', 'x']); + }); + it('reuses the cached row array for repeated identical inputs', () => { const first = wrapPromptText('cache\nme', 10); const second = wrapPromptText('cache\nme', 10); diff --git a/tui/src/libs/composer/composerWindow.ts b/tui/src/libs/composer/composerWindow.ts index d46fda30..5bac40c1 100644 --- a/tui/src/libs/composer/composerWindow.ts +++ b/tui/src/libs/composer/composerWindow.ts @@ -1,6 +1,11 @@ import { clamp } from '@libs/math/clamp.ts'; import { wrapPromptText } from '@libs/composer/wrapPromptText.ts'; import type { WrappedPromptRow } from '@libs/composer/wrapPromptText.ts'; +import { + clampToGraphemeBoundary, + displayWidthBeforeIndex, + indexAtDisplayColumn +} from '@libs/text/displayWidth.ts'; export type ComposerWindowParams = { text: string; @@ -40,7 +45,7 @@ export function resolveComposerWindow(params: ComposerWindowParams): ComposerWin const { text, columns, maxVisibleLines, cursorIndex, offset = 0 } = params; const safeMaxVisibleLines = Math.max(1, maxVisibleLines); const rows = wrapPromptText(text, columns); - const safeCursorIndex = clamp(cursorIndex, 0, text.length); + const safeCursorIndex = clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); // Cursor-follow baseline (slide up only when the cursor would fall below the // last visible row); the signed offset then shifts the window from there. const { visibleStart, lastStart, baseStart, cursorRowIndex } = resolveWindowBounds( @@ -73,7 +78,7 @@ export function resolveScrollIntoViewOffset(params: ComposerWindowParams): numbe const { text, columns, maxVisibleLines, cursorIndex, offset = 0 } = params; const safeMaxVisibleLines = Math.max(1, maxVisibleLines); const rows = wrapPromptText(text, columns); - const safeCursorIndex = clamp(cursorIndex, 0, text.length); + const safeCursorIndex = clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); const { visibleStart, baseStart, cursorRowIndex } = resolveWindowBounds( rows, safeMaxVisibleLines, @@ -107,7 +112,7 @@ export function resolveClickResult( } const rows = wrapPromptText(text, columns); - const safeCursorIndex = clamp(cursorIndex, 0, text.length); + const safeCursorIndex = clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); const { visibleStart, lastStart } = resolveWindowBounds( rows, safeMaxVisibleLines, @@ -120,7 +125,7 @@ export function resolveClickResult( } const row = rows[targetRowIndex]; - const index = row.start + Math.min(Math.max(0, column), row.end - row.start); + const index = row.start + indexAtDisplayColumn(row.text, column); // Keep the window fixed: reproduce the current visibleStart against the NEW // cursor's follow baseline. Resolve the cursor row the same way // resolveComposerWindow does — at a soft-wrap boundary `index === row.start === @@ -169,16 +174,19 @@ export function resolveVerticalCursorIndex( direction: VerticalDirection ): number | null { const rows = wrapPromptText(text, columns); - const safeCursorIndex = clamp(cursorIndex, 0, text.length); + const safeCursorIndex = clampToGraphemeBoundary(text, clamp(cursorIndex, 0, text.length)); const currentRow = resolveCursorRowIndex(rows, safeCursorIndex); const targetRow = currentRow + (direction === 'up' ? -1 : 1); if (targetRow < 0 || targetRow >= rows.length) { return null; } - const column = safeCursorIndex - rows[currentRow].start; + const column = displayWidthBeforeIndex( + rows[currentRow].text, + safeCursorIndex - rows[currentRow].start + ); const target = rows[targetRow]; - return target.start + Math.min(column, target.end - target.start); + return target.start + indexAtDisplayColumn(target.text, column); } function resolveVisibleCursorIndex(rows: WrappedPromptRow[], cursorIndex: number): number { diff --git a/tui/src/libs/composer/wrapPromptText.ts b/tui/src/libs/composer/wrapPromptText.ts index 36811b49..2c53a860 100644 --- a/tui/src/libs/composer/wrapPromptText.ts +++ b/tui/src/libs/composer/wrapPromptText.ts @@ -1,3 +1,5 @@ +import { measureGraphemes } from '@libs/text/displayWidth.ts'; + /** One wrapped visual row of the prompt, with its source index range. */ export type WrappedPromptRow = { text: string; @@ -17,9 +19,8 @@ let cachedRows: WrappedPromptRow[] | undefined; /** * Splits `text` into visual rows: authored newlines start new rows, and each - * logical line is chunked every `columns` characters. An empty prompt yields a - * single empty row. Pass the prompt's input width (terminal columns minus the - * prompt prefix) so wrapping matches what the composer renders. + * logical line is wrapped to `columns` terminal display columns. An empty prompt + * yields a single empty row. Wide and multi-code-point graphemes stay intact. * * The last `(text, columns)` result is memoized, so the repeated wraps of the * current prompt within a keystroke reuse one computation. @@ -56,18 +57,7 @@ function computeWrappedPromptRows(text: string, safeColumns: number): WrappedPro const rawLine = text.slice(lineStart, lineEnd); const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; - if (line.length === 0) { - rows.push({ text: '', start: lineStart, end: lineStart }); - } else { - for (let offset = 0; offset < line.length; offset += safeColumns) { - const endOffset = Math.min(offset + safeColumns, line.length); - rows.push({ - text: line.slice(offset, endOffset), - start: lineStart + offset, - end: lineStart + endOffset - }); - } - } + appendLineRows(rows, line, lineStart, safeColumns); if (newlineIndex < 0) { break; @@ -78,3 +68,42 @@ function computeWrappedPromptRows(text: string, safeColumns: number): WrappedPro return rows; } + +function appendLineRows( + rows: WrappedPromptRow[], + line: string, + lineStart: number, + safeColumns: number +): void { + if (line.length === 0) { + rows.push({ text: '', start: lineStart, end: lineStart }); + return; + } + + let rowStart = 0; + let offset = 0; + let rowWidth = 0; + for (const { segment, width } of measureGraphemes(line)) { + if (rowWidth + width > safeColumns && offset > rowStart) { + rows.push(rowSlice(line, lineStart, rowStart, offset)); + rowStart = offset; + rowWidth = 0; + } + rowWidth += width; + offset += segment.length; + } + rows.push(rowSlice(line, lineStart, rowStart, offset)); +} + +function rowSlice( + line: string, + lineStart: number, + start: number, + end: number +): WrappedPromptRow { + return { + text: line.slice(start, end), + start: lineStart + start, + end: lineStart + end + }; +} diff --git a/tui/src/libs/text/displayWidth.ts b/tui/src/libs/text/displayWidth.ts new file mode 100644 index 00000000..79aa3702 --- /dev/null +++ b/tui/src/libs/text/displayWidth.ts @@ -0,0 +1,119 @@ +import stringWidth from 'string-width'; + +/** One grapheme cluster paired with its terminal display width in columns. */ +export type MeasuredGrapheme = { + segment: string; + width: number; + start: number; + end: number; +}; + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + +/** Terminal display width of `text`, using the same measurement as Ink. */ +export function displayWidth(text: string): number { + return stringWidth(text); +} + +/** Splits `text` into grapheme clusters with source offsets and display widths. */ +export function measureGraphemes(text: string): MeasuredGrapheme[] { + const measured: MeasuredGrapheme[] = []; + let start = 0; + for (const { segment } of graphemeSegmenter.segment(text)) { + const end = start + segment.length; + measured.push({ segment, width: stringWidth(segment), start, end }); + start = end; + } + return measured; +} + +/** Right-pads `text` to `width` display columns without truncating it. */ +export function padEndToWidth(text: string, width: number): string { + const padding = width - displayWidth(text); + return padding > 0 ? text + ' '.repeat(padding) : text; +} + +/** Previous grapheme boundary at or before `index`. */ +export function previousGraphemeStart(text: string, index: number): number { + const safeIndex = Math.max(0, Math.min(index, text.length)); + if (safeIndex === 0) { + return 0; + } + + let previousEnd = 0; + for (const grapheme of measureGraphemes(text)) { + if (safeIndex <= grapheme.start) { + return previousEnd; + } + if (safeIndex <= grapheme.end) { + return grapheme.start; + } + previousEnd = grapheme.end; + } + return previousEnd; +} + +/** Next grapheme boundary at or after `index`. */ +export function nextGraphemeEnd(text: string, index: number): number { + const safeIndex = Math.max(0, Math.min(index, text.length)); + for (const grapheme of measureGraphemes(text)) { + if (safeIndex < grapheme.end) { + return grapheme.end; + } + } + return text.length; +} + +/** Clamps `index` to the preceding grapheme boundary. */ +export function clampToGraphemeBoundary(text: string, index: number): number { + const safeIndex = Math.max(0, Math.min(index, text.length)); + if (safeIndex === 0 || safeIndex === text.length) { + return safeIndex; + } + + for (const grapheme of measureGraphemes(text)) { + if (safeIndex === grapheme.start || safeIndex === grapheme.end) { + return safeIndex; + } + if (safeIndex < grapheme.end) { + return grapheme.start; + } + } + return safeIndex; +} + +/** Display columns occupied before `index` within `text`. */ +export function displayWidthBeforeIndex(text: string, index: number): number { + const safeIndex = clampToGraphemeBoundary(text, index); + let width = 0; + for (const grapheme of measureGraphemes(text)) { + if (safeIndex <= grapheme.start) { + break; + } + width += grapheme.width; + if (safeIndex === grapheme.end) { + break; + } + } + return width; +} + +/** Grapheme boundary at display `column` within `text`. */ +export function indexAtDisplayColumn(text: string, column: number): number { + const safeColumn = Math.max(0, column); + let width = 0; + for (const grapheme of measureGraphemes(text)) { + if (safeColumn <= width) { + return grapheme.start; + } + const nextWidth = width + grapheme.width; + if (safeColumn < nextWidth) { + return grapheme.start; + } + if (safeColumn === nextWidth) { + return grapheme.end; + } + width = nextWidth; + } + return text.length; +} diff --git a/tui/src/libs/tui/__tests__/cwdLine.test.ts b/tui/src/libs/tui/__tests__/cwdLine.test.ts index b8b09e3b..bda0bc6b 100644 --- a/tui/src/libs/tui/__tests__/cwdLine.test.ts +++ b/tui/src/libs/tui/__tests__/cwdLine.test.ts @@ -1,61 +1,62 @@ import { describe, expect, it } from 'vitest'; -import { formatCwdLine, pullRequestLabelOffset, renderCwdLine } from '@libs/tui/cwdLine.ts'; - -const statusWithPr = { - label: '⎇ main*', - pullRequestLabel: '#3', - pullRequestUrl: 'https://github.com/o/r/pull/3' -}; - -/** Removes OSC 8 and SGR escapes so decorated text can be compared to plain. */ -function stripEscapes(text: string): string { - return text.replace(/\u001B\]8;;[^\u0007]*\u0007/g, '').replace(/\u001B\[[0-9:;]*m/g, ''); -} - -describe('formatCwdLine (plain, drives width math)', () => { - it('joins the cwd, git, and PR segments as plain text', () => { - expect(formatCwdLine('/tmp/x', statusWithPr)).toContain(' [⎇ main*] [#3]'); - }); - - it('never contains escape sequences, even when a PR url is present', () => { - expect(formatCwdLine('/tmp/x', statusWithPr)).not.toContain('\u001B'); - }); -}); - -describe('renderCwdLine (decorated, for display)', () => { - it('wraps the PR label in a dotted underline + OSC 8 hyperlink when a url is present', () => { - expect(renderCwdLine('/tmp/x', statusWithPr)).toContain( - ' [\u001B]8;;https://github.com/o/r/pull/3\u0007\u001B[4m\u001B[4:3m#3\u001B[24m\u001B]8;;\u0007]' +import type { GitStatus } from '@contracts/backend/index.ts'; +import { + countCwdRows, + formatCwdLine, + pullRequestLabelOffset, + renderCwdLine +} from '@libs/tui/cwdLine.ts'; + +describe('formatCwdLine', () => { + it('joins the cwd and git status as plain text', () => { + expect(formatCwdLine('/tmp/x', { label: '⎇ main*' })).toContain('/tmp/x [⎇ main*]'); + }); + + it('appends the pull-request label as plain text', () => { + expect(formatCwdLine('/tmp/x', { label: '⎇ main*', pullRequestLabel: '#3' })).toBe( + '/tmp/x [⎇ main*] [#3]' ); }); - it('leaves the PR label plain when there is no url, so the affordance is never a dead link', () => { - const line = renderCwdLine('/tmp/x', { label: '⎇ main', pullRequestLabel: '#3' }); - expect(line).toContain(' [#3]'); - expect(line).not.toContain('\u001B'); + it('sanitizes terminal controls from the cwd and git status', () => { + expect( + formatCwdLine('/tmp/\u001B[31mx', { + label: '⎇ main\u0007', + pullRequestLabel: '#3\u001B' + }) + ).toBe('/tmp/\\x1b[31mx [⎇ main\\x07] [#3\\x1b]'); + }); + + it('renders the pull-request label as a hyperlink when a url is present', () => { + expect( + renderCwdLine('/tmp/x', { + label: '⎇ main*', + pullRequestLabel: '#3', + pullRequestUrl: 'https://github.com/o/r/pull/3' + }) + ).toContain( + '\u001B]8;;https://github.com/o/r/pull/3\u0007\u001B[4m\u001B[4:3m#3\u001B[24m' + ); }); - it('adds only zero-width escapes, so its visible text equals formatCwdLine', () => { - expect(stripEscapes(renderCwdLine('/tmp/x', statusWithPr))).toBe( - formatCwdLine('/tmp/x', statusWithPr) + it('escapes tabs and newlines so the cwd remains one logical row', () => { + expect(formatCwdLine('/tmp/a\nb', { label: '⎇ main\tbad' })).toBe( + '/tmp/a\\x0ab [⎇ main\\x09bad]' ); + expect(countCwdRows('/tmp/a\nb', undefined, 80)).toBe(1); }); +}); - it('matches formatCwdLine exactly when there is no git status', () => { - expect(renderCwdLine('/tmp/x', undefined)).toBe(formatCwdLine('/tmp/x', undefined)); +describe('countCwdRows', () => { + it('uses terminal display width for wide glyphs', () => { + expect(countCwdRows('/界界', undefined, 4)).toBe(2); }); }); describe('pullRequestLabelOffset', () => { - it('points at the first character of the PR label within the plain line', () => { - const offset = pullRequestLabelOffset('/tmp/x', statusWithPr); - const line = formatCwdLine('/tmp/x', statusWithPr); - expect(offset).toBeGreaterThan(0); - // The label runs to the end of the line, just before the closing bracket. - expect(line.slice(offset)).toBe('#3]'); - }); + it('returns the display-column offset for the pull-request label', () => { + const gitStatus: GitStatus = { label: '⎇ 主', pullRequestLabel: '#3' }; - it('is undefined when there is no PR label', () => { - expect(pullRequestLabelOffset('/tmp/x', { label: '⎇ main' })).toBeUndefined(); + expect(pullRequestLabelOffset('/界', gitStatus)).toBe(12); }); }); diff --git a/tui/src/libs/tui/cwdLine.ts b/tui/src/libs/tui/cwdLine.ts index 645650af..03d44b18 100644 --- a/tui/src/libs/tui/cwdLine.ts +++ b/tui/src/libs/tui/cwdLine.ts @@ -1,12 +1,15 @@ import os from 'node:os'; import path from 'node:path'; import type { GitStatus } from '@contracts/backend/index.ts'; +import { displayWidth } from '@libs/text/displayWidth.ts'; +import { sanitizeDisplayText } from '@libs/text/sanitizeDisplayText.ts'; import { dottedUnderline, hyperlink } from '@libs/terminal/hyperlink.ts'; /** The cwd + git-label prefix shared by the plain and decorated renderings. */ function cwdAndGitPrefix(workspaceCwd: string, gitStatus?: GitStatus): string { - const cwdSegment = formatDisplayCwd(workspaceCwd); - const gitSegment = gitStatus === undefined ? '' : ` [${gitStatus.label}]`; + const cwdSegment = sanitizeSingleLine(formatDisplayCwd(workspaceCwd)); + const gitSegment = + gitStatus === undefined ? '' : ` [${sanitizeSingleLine(gitStatus.label)}]`; return `${cwdSegment}${gitSegment}`; } @@ -19,7 +22,9 @@ function cwdAndGitPrefix(workspaceCwd: string, gitStatus?: GitStatus): string { */ export function formatCwdLine(workspaceCwd: string, gitStatus?: GitStatus): string { const pullRequestSegment = - gitStatus?.pullRequestLabel === undefined ? '' : ` [${gitStatus.pullRequestLabel}]`; + gitStatus?.pullRequestLabel === undefined + ? '' + : ` [${sanitizeSingleLine(gitStatus.pullRequestLabel)}]`; return `${cwdAndGitPrefix(workspaceCwd, gitStatus)}${pullRequestSegment}`; } @@ -44,14 +49,16 @@ function renderPullRequestSegment(gitStatus?: GitStatus): string { if (label === undefined) { return ''; } + const sanitizedLabel = sanitizeSingleLine(label); const url = gitStatus?.pullRequestUrl; - const rendered = url === undefined ? label : hyperlink(dottedUnderline(label), url); + const rendered = + url === undefined ? sanitizedLabel : hyperlink(dottedUnderline(sanitizedLabel), url); return ` [${rendered}]`; } /** - * The visible character offset at which the pull-request label (e.g. `#3`) - * starts within {@link formatCwdLine}, or `undefined` when no PR label is shown. + * The visible column offset at which the pull-request label (e.g. `#3`) starts + * within {@link formatCwdLine}, or `undefined` when no PR label is shown. * * The click router uses it to map a pointer position back to the label's span. * The PR segment is ` [