diff --git a/Cargo.lock b/Cargo.lock index e9a18bb8..2ad2f74a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "agent-client-protocol" version = "0.10.4" @@ -166,6 +172,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -398,6 +413,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -671,6 +695,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fancy-regex" version = "0.17.0" @@ -694,6 +729,22 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1253,6 +1304,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1399,6 +1456,16 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -1498,6 +1565,7 @@ dependencies = [ "serde_json", "tempfile", "toml", + "toml_edit", "uuid", ] @@ -1606,6 +1674,7 @@ dependencies = [ "crossbeam-channel", "crossterm", "dirs", + "libc", "orca-approval", "orca-core", "orca-file-search", @@ -1614,11 +1683,18 @@ dependencies = [ "orca-runtime", "orca-tools", "pulldown-cmark", + "qwertty", "ratatui", "serde", "serde_json", + "similar", + "supports-color", + "syntect", "tempfile", + "tokio", "tui-textarea", + "two-face", + "unicode-segmentation", "unicode-width 0.2.0", ] @@ -1793,6 +1869,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "qwertty" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f74f6ff66f377821e9800226391eb9962a7ed3b6f2e66c091777a0519b4b277" +dependencies = [ + "rustix 1.1.4", + "tokio", + "unicode-segmentation", + "unicode-width 0.2.0", + "windows-sys 0.61.2", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -2288,6 +2377,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "similar" version = "3.1.1" @@ -2386,6 +2481,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "syn" version = "2.0.117" @@ -2417,6 +2521,24 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex 0.16.2", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror", + "walkdir", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -2465,7 +2587,7 @@ dependencies = [ "anyhow", "base64", "bstr", - "fancy-regex", + "fancy-regex 0.17.0", "lazy_static", "regex", "rustc-hash", @@ -2669,6 +2791,17 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "two-face" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34" +dependencies = [ + "serde", + "serde_derive", + "syntect", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index 88f57748..083027ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ reqwest = { version = "0.12.24", default-features = false, features = ["blocking rusqlite = { version = "0.32", features = ["bundled"] } hickory-resolver = "0.25.2" toml = "0.8" +toml_edit = "0.22" tiktoken-rs = "0.12.0" dirs = "6" globset = "0.4" @@ -28,13 +29,18 @@ crossbeam-channel = "0.5" crossterm = "0.28" ratatui = "0.29" tui-textarea = "0.7" +qwertty = { version = "=0.1.6", features = ["tokio"] } +supports-color = "3.0.2" pulldown-cmark = { version = "0.12", default-features = false } +unicode-segmentation = "1.13.3" unicode-width = "0.2.0" zstd = "0.13" similar = "3.1.1" tempfile = "3.27.0" sha2 = "0.10" base64 = "0.22" +syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "dump-load", "parsing", "regex-fancy"] } +two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] } libc = "0.2" landlock = "0.4.5" seccompiler = "0.5.0" diff --git a/README.md b/README.md index 40e5c951..1239b490 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,42 @@ In the TUI, `@` searches files, skills, plugins, and MCP resources. Use `/workflows` for background work, and `/trust` to manage the current folder's sandbox permissions. +### First-run onboarding + +When the TUI starts without an effective API key, first-run onboarding follows exactly seven steps: +Welcome → Provider → API Key → Model → Theme → Review → Complete. +DeepSeek is the only production provider; development-only providers are not +shown. Model choices are `auto`, +`deepseek-v4-flash`, and `deepseek-v4-pro`. Theme choices are Auto, Dark, +Light, Solarized, and Catppuccin. + +Before Review, the API key is draft-only. Pressing Esc before confirming Review +exits with zero writes, including when the Review page is open +but before pressing Enter. Setup performs no network validation. Confirming Review +writes the provider, model, and theme to `config.toml`, while the API key is +stored separately in `auth.json`. If either save fails, the selected values +remain applied to the current session. Complete reports only sanitized error categories. + +### Doctor diagnostics + +`/doctor` emits one safe, copyable diagnostics report from facts already +captured by the current TUI session. It includes the Orca version and platform, +terminal and multiplexer identity, effective color/background/theme, +notification and input posture, viewport/session/keybindings state, and bounded +frame metrics. It excludes secrets, raw environment values, conversation +transcripts, the current working directory, and absolute filesystem paths. The +command does not run shell commands, access files, or re-probe the terminal. + +The optional FPS HUD is default-off and session-only: + +- `/doctor fps` toggles it. +- `/doctor fps on` enables it. +- `/doctor fps off` disables it. + +FPS is the rate of successful terminal output frames. `render-ms` is the actual +duration of `terminal.draw`, not scheduler wake-up time or the interval between +frames. + ## What it does - Uses DeepSeek's reasoning and tool-use semantics directly, with SSE streaming, @@ -64,6 +100,83 @@ then defaults. Run `orca --help` or `orca exec --help` for the full command surface. User configuration lives at `~/.orca/config.toml`; trusted projects can also provide `.orca/config.toml`, `AGENTS.md`, rules, skills, and workflows. +### Custom keybindings + +The TUI loads personal keybindings from `~/.orca/keybindings.json`, or from +`$ORCA_HOME/keybindings.json` when `ORCA_HOME` is set. There is deliberately no +project-local keybindings file, so opening a repository cannot change your +terminal controls. + +```json +{ + "version": 1, + "bindings": { + "global.open-transcript-search": ["ctrl+f", "ctrl+x ctrl+f"], + "idle.submit": ["ctrl+s"], + "running.interrupt": ["esc", "ctrl+g"], + "approval.confirm": ["enter"] + } +} +``` + +Each listed action replaces its built-in bindings; omitted actions keep their +defaults, and an empty array disables an action. A sequence contains one to +four space-separated strokes, such as `ctrl+x ctrl+f`. Chords have a fixed +1 second timeout. Binding contexts are `global`, `idle`, `running`, and +`approval`. + +Available action IDs: + +```text +global.cancel +global.open-transcript-search +global.toggle-shortcuts +global.scroll-bottom +global.scroll-top +global.clear-screen + +idle.submit +idle.newline +idle.edit-latest-queued +idle.history-previous +idle.history-next +idle.scroll-up +idle.scroll-down +idle.page-up +idle.page-down +idle.half-page-up +idle.half-page-down +idle.backtrack +idle.expand-tool-output + +running.background-current-turn +running.interrupt +running.submit-queued +running.newline +running.edit-latest-queued +running.scroll-up +running.scroll-down +running.page-up +running.page-down +running.half-page-up +running.half-page-down + +approval.select-allow +approval.select-deny +approval.toggle-selection +approval.confirm +``` + +`global.cancel` must keep an immediate single-stroke binding. Configurable +global bindings use function keys or modified character keys, which prevents +them from shadowing fixed modal controls. Approval direct keys +`1/2/3/4/y/a/A/n/d` are fixed and reserved. + +The TUI checks the file every 500 ms and performs a live reload without +blocking input or rendering. A valid edit swaps the complete keymap atomically. +An invalid edit reports one notice and keeps the last-known-good keymap. +Deleting the file restores all built-in bindings. + More detail: - [Persistent Goal Mode](docs/goal-mode.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index cdab7873..5a37dec7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,6 +40,37 @@ orca --mode=acp # 连接 ACP 客户端 使用 `/plan` 进行只读规划,使用 `/goal` 管理持久目标,使用 `/workflows` 查看后台任务,使用 `/trust` 管理当前目录的沙箱权限。 +### 首次启动设置 + +当 TUI 启动时未检测到有效 API 密钥,首次启动设置固定经过七步: +欢迎 → 服务商 → API 密钥 → 模型 → 主题 → 确认 → 完成。 +DeepSeek 是唯一的生产服务商,不会显示仅供开发使用的服务商。模型可选 `auto`、 +`deepseek-v4-flash`、`deepseek-v4-pro`;主题可选 Auto、Dark、Light、Solarized、 +Catppuccin。 + +确认前,API 密钥仅保存在草稿中。在确认“确认”步骤前按 Esc 都会退出且不会产生任何写入, +包括已进入“确认”页面但尚未按 Enter 时。 +设置期间不进行网络验证。确认后,服务商、模型和主题写入 `config.toml`,API 密钥 +单独写入 `auth.json`。任一保存失败时,所选值仍应用于当前会话。完成步骤仅显示不含敏感信息的错误类型。 + +### Doctor 诊断 + +`/doctor` 会根据当前 TUI 会话已经捕获的事实,输出一份安全且便于复制的诊断报告。 +报告包含 Orca 版本与平台、终端与多路复用器、实际生效的颜色/背景/主题、通知与输入 +能力、视口/会话/快捷键状态,以及有界的帧指标。报告不会包含 secrets、原始环境变量、 +对话 transcript、当前工作目录或绝对文件路径;命令不会运行 Shell、读取文件或 +re-probe 终端。 + +可选的 FPS HUD 默认为关闭(default-off),并且仅对当前会话生效 +(session-only): + +- `/doctor fps` 切换显示状态。 +- `/doctor fps on` 开启显示。 +- `/doctor fps off` 关闭显示。 + +FPS 表示成功输出到终端的帧率;`render-ms` 表示实际 `terminal.draw` 耗时, +不是调度器唤醒时间或帧间隔。 + ## 核心能力 - 直接适配 DeepSeek 的推理和工具调用语义,支持 SSE 流式输出、前缀缓存友好提示词、 @@ -57,6 +88,79 @@ orca --mode=acp # 连接 ACP 客户端 或 `orca exec --help` 查看完整命令。用户配置位于 `~/.orca/config.toml`; 受信任的项目还可以提供 `.orca/config.toml`、`AGENTS.md`、规则、Skills 和工作流。 +### 自定义快捷键 + +TUI 从 `~/.orca/keybindings.json` 加载个人快捷键;设置 `ORCA_HOME` 后,路径为 +`$ORCA_HOME/keybindings.json`。Orca 不读取项目内的快捷键文件,避免打开仓库时 +被项目改变终端控制方式。 + +```json +{ + "version": 1, + "bindings": { + "global.open-transcript-search": ["ctrl+f", "ctrl+x ctrl+f"], + "idle.submit": ["ctrl+s"], + "running.interrupt": ["esc", "ctrl+g"], + "approval.confirm": ["enter"] + } +} +``` + +文件中出现的 action 会替换其内置绑定;未出现的 action 保留默认值,空数组会禁用 +该 action。一个序列包含一到四个以空格分隔的按键,例如 +`ctrl+x ctrl+f`。Chord 的固定超时时间为 1 second。可用 context 为 +`global`、`idle`、`running` 和 `approval`。 + +可配置的 action ID: + +```text +global.cancel +global.open-transcript-search +global.toggle-shortcuts +global.scroll-bottom +global.scroll-top +global.clear-screen + +idle.submit +idle.newline +idle.edit-latest-queued +idle.history-previous +idle.history-next +idle.scroll-up +idle.scroll-down +idle.page-up +idle.page-down +idle.half-page-up +idle.half-page-down +idle.backtrack +idle.expand-tool-output + +running.background-current-turn +running.interrupt +running.submit-queued +running.newline +running.edit-latest-queued +running.scroll-up +running.scroll-down +running.page-up +running.page-down +running.half-page-up +running.half-page-down + +approval.select-allow +approval.select-deny +approval.toggle-selection +approval.confirm +``` + +`global.cancel` 必须保留至少一个可立即触发的单键绑定。自定义 Global 绑定只能使用 +功能键或带修饰键的字符键,避免覆盖 Esc、Enter、方向键、Tab 等固定模态控制。 +审批直接键 `1/2/3/4/y/a/A/n/d` 固定且保留。 + +TUI 每 500 ms 检查一次文件,并在不阻塞输入和渲染的前提下 live reload。 +有效修改会原子替换完整 keymap;无效修改只提示一次,并继续使用 +last-known-good keymap。删除文件会恢复全部内置绑定。 + 更多文档: - [持久 Goal 模式](docs/goal-mode.md) diff --git a/crates/orca-core/Cargo.toml b/crates/orca-core/Cargo.toml index 3254d0e9..1b25e946 100644 --- a/crates/orca-core/Cargo.toml +++ b/crates/orca-core/Cargo.toml @@ -10,6 +10,7 @@ clap = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } dirs = { workspace = true } libc = { workspace = true } diff --git a/crates/orca-core/src/config/file.rs b/crates/orca-core/src/config/file.rs index 02e47242..ad22e35d 100644 --- a/crates/orca-core/src/config/file.rs +++ b/crates/orca-core/src/config/file.rs @@ -1,6 +1,8 @@ -use std::collections::HashMap; -use std::fs; +use std::collections::{BTreeMap, HashMap}; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use serde::Deserialize; use toml::Value; @@ -9,16 +11,157 @@ use crate::approval_rules::PermissionRules; use crate::approval_types::ApprovalMode; use crate::config::{ DEFAULT_MAX_WORKFLOW_AGENTS_PER_RUN, DEFAULT_MAX_WORKFLOW_CONCURRENT_AGENTS, - MAX_WORKFLOW_AGENT_RETRIES, ModelRuntimeConfig, PermissionProfileConfig, ReasoningEffort, - ThemeName, ToolConfig, WorkflowConfig, WorkflowTeamConfig, + MAX_WORKFLOW_AGENT_RETRIES, ModelRuntimeConfig, PermissionProfileConfig, ProviderKind, + ReasoningEffort, ThemeName, ToolConfig, WorkflowConfig, WorkflowTeamConfig, }; use crate::subagent_config::SubagentConfig; const ORCA_HOME_ENV: &str = "ORCA_HOME"; +pub const MAX_USER_CONFIG_BYTES: usize = 1024 * 1024; +pub const MAX_AUTH_FILE_BYTES: usize = 1024 * 1024; + +static USER_FILE_TEMP_COUNTER: AtomicU64 = AtomicU64::new(1); + +#[cfg(target_os = "macos")] +const MACOS_ACL_TYPE_EXTENDED: libc::c_int = 0x0000_0100; +#[cfg(all(target_os = "macos", test))] +const MACOS_ACL_FIRST_ENTRY: libc::c_int = 0; + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn acl_init(count: libc::c_int) -> *mut libc::c_void; + fn acl_free(object: *mut libc::c_void) -> libc::c_int; + fn acl_set_fd_np(fd: libc::c_int, acl: *mut libc::c_void, acl_type: libc::c_int) + -> libc::c_int; +} + +#[cfg(all(target_os = "macos", test))] +unsafe extern "C" { + fn acl_from_text(text: *const libc::c_char) -> *mut libc::c_void; + fn acl_get_fd_np(fd: libc::c_int, acl_type: libc::c_int) -> *mut libc::c_void; + fn acl_get_entry( + acl: *mut libc::c_void, + entry_id: libc::c_int, + entry: *mut *mut libc::c_void, + ) -> libc::c_int; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UserPreferenceValidationError { + UnsupportedProvider, + UnsupportedModel, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UserConfigSaveError { + ConfigDirectoryUnavailable, + UnsafeExistingPath, + ExistingFileTooLarge, + InvalidExistingContent, + ConcurrentModification, + CreateDirectoryFailed, + CreateTemporaryFileFailed, + ReadFailed, + WriteFailed, + ReplaceFailed, + RollbackFailed, +} + +#[cfg(test)] +const USER_CONFIG_SAVE_ERROR_LABELS: [(UserConfigSaveError, &str); 11] = [ + ( + UserConfigSaveError::ConfigDirectoryUnavailable, + "config directory unavailable", + ), + ( + UserConfigSaveError::UnsafeExistingPath, + "unsafe existing config path", + ), + ( + UserConfigSaveError::ExistingFileTooLarge, + "existing config is too large", + ), + ( + UserConfigSaveError::InvalidExistingContent, + "invalid existing config", + ), + ( + UserConfigSaveError::ConcurrentModification, + "config changed during save", + ), + ( + UserConfigSaveError::CreateDirectoryFailed, + "could not create config directory", + ), + ( + UserConfigSaveError::CreateTemporaryFileFailed, + "could not create temporary config", + ), + ( + UserConfigSaveError::ReadFailed, + "could not read existing config", + ), + (UserConfigSaveError::WriteFailed, "could not write config"), + ( + UserConfigSaveError::ReplaceFailed, + "could not replace config", + ), + ( + UserConfigSaveError::RollbackFailed, + "could not restore concurrent config", + ), +]; + +impl UserConfigSaveError { + pub const fn safe_label(self) -> &'static str { + match self { + Self::ConfigDirectoryUnavailable => "config directory unavailable", + Self::UnsafeExistingPath => "unsafe existing config path", + Self::ExistingFileTooLarge => "existing config is too large", + Self::InvalidExistingContent => "invalid existing config", + Self::ConcurrentModification => "config changed during save", + Self::CreateDirectoryFailed => "could not create config directory", + Self::CreateTemporaryFileFailed => "could not create temporary config", + Self::ReadFailed => "could not read existing config", + Self::WriteFailed => "could not write config", + Self::ReplaceFailed => "could not replace config", + Self::RollbackFailed => "could not restore concurrent config", + } + } +} + +#[derive(Debug)] +pub struct UserPreferencePatch { + provider: ProviderKind, + model: String, + theme: ThemeName, +} + +impl UserPreferencePatch { + pub fn new( + provider: ProviderKind, + model: impl Into, + theme: ThemeName, + ) -> Result { + if provider != ProviderKind::DeepSeek { + return Err(UserPreferenceValidationError::UnsupportedProvider); + } + let model = model.into(); + if !crate::model::allowed_models().contains(&model.as_str()) { + return Err(UserPreferenceValidationError::UnsupportedModel); + } + Ok(Self { + provider, + model, + theme, + }) + } +} #[derive(Clone, Debug, Deserialize)] #[serde(from = "RawFileConfig")] pub struct FileConfig { + pub provider: ProviderKind, pub model: Option, pub mode: Option, pub api_key: Option, @@ -44,16 +187,22 @@ pub struct FileConfig { pub theme: ThemeName, #[serde(default)] pub vim_mode: bool, + #[serde(default)] + pub vim_insert_escape: Option, #[serde(default = "default_true")] pub update_check: bool, #[serde(default)] pub desktop_notifications: bool, + #[serde(default = "default_true")] + pub terminal_notifications: bool, #[serde(default)] pub auto_memory: bool, } #[derive(Clone, Debug, Deserialize)] struct RawFileConfig { + #[serde(default = "default_provider")] + pub provider: ProviderKind, pub model: Option, pub mode: Option, pub api_key: Option, @@ -86,10 +235,14 @@ struct RawFileConfig { pub theme: ThemeName, #[serde(default)] pub vim_mode: bool, + #[serde(default)] + pub vim_insert_escape: Option, #[serde(default = "default_true")] pub update_check: bool, #[serde(default)] pub desktop_notifications: bool, + #[serde(default = "default_true")] + pub terminal_notifications: bool, #[serde(default)] pub auto_memory: bool, } @@ -97,6 +250,7 @@ struct RawFileConfig { impl Default for FileConfig { fn default() -> Self { Self { + provider: default_provider(), model: None, mode: None, api_key: None, @@ -112,8 +266,10 @@ impl Default for FileConfig { workflows: WorkflowFileConfig::default(), theme: ThemeName::default(), vim_mode: false, + vim_insert_escape: None, update_check: true, desktop_notifications: false, + terminal_notifications: true, auto_memory: false, } } @@ -129,6 +285,7 @@ impl From for FileConfig { ); Self { + provider: raw.provider, model: raw.model, mode: raw.mode, api_key: raw.api_key, @@ -144,8 +301,10 @@ impl From for FileConfig { workflows, theme: raw.theme, vim_mode: raw.vim_mode, + vim_insert_escape: raw.vim_insert_escape, update_check: raw.update_check, desktop_notifications: raw.desktop_notifications, + terminal_notifications: raw.terminal_notifications, auto_memory: raw.auto_memory, } } @@ -153,6 +312,7 @@ impl From for FileConfig { #[derive(Clone, Debug, Default)] pub struct ConfigOverrides { + pub provider: Option, pub model: Option, pub mode: Option, pub api_key: Option, @@ -249,12 +409,644 @@ fn default_true() -> bool { true } +fn default_provider() -> ProviderKind { + ProviderKind::DeepSeek +} + fn config_dir() -> Option { std::env::var_os(ORCA_HOME_ENV) .map(PathBuf::from) .or_else(|| dirs::home_dir().map(|h| h.join(".orca"))) } +fn patch_user_preferences( + source: &str, + patch: &UserPreferencePatch, +) -> Result { + use toml_edit::DocumentMut; + + let mut document = if source.trim().is_empty() { + DocumentMut::new() + } else { + source + .parse::() + .map_err(|_| UserConfigSaveError::InvalidExistingContent)? + }; + debug_assert_eq!(patch.provider, ProviderKind::DeepSeek); + patch_document_string(&mut document, "provider", "deep-seek"); + patch_document_string(&mut document, "model", patch.model.as_str()); + patch_document_string(&mut document, "theme", patch.theme.as_str()); + Ok(document.to_string()) +} + +fn patch_document_string(document: &mut toml_edit::DocumentMut, key: &str, replacement: &str) { + let decor = document + .get(key) + .and_then(toml_edit::Item::as_value) + .map(|value| value.decor().clone()); + let mut value = toml_edit::Value::from(replacement); + if let Some(decor) = decor { + *value.decor_mut() = decor; + } + document[key] = toml_edit::Item::Value(value); +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct UserFileIdentity { + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, + #[cfg(not(unix))] + len: u64, + #[cfg(not(unix))] + modified: Option, +} + +impl UserFileIdentity { + fn from_metadata(metadata: &std::fs::Metadata) -> Self { + #[cfg(unix)] + use std::os::unix::fs::MetadataExt; + + Self { + #[cfg(unix)] + device: metadata.dev(), + #[cfg(unix)] + inode: metadata.ino(), + #[cfg(not(unix))] + len: metadata.len(), + #[cfg(not(unix))] + modified: metadata.modified().ok(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExistingUserFile { + bytes: Vec, + identity: UserFileIdentity, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ExpectedUserFile { + Missing, + Existing(ExistingUserFile), +} + +fn read_optional_regular_file( + path: &Path, + max_bytes: usize, +) -> Result, UserConfigSaveError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(UserConfigSaveError::ReadFailed), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(UserConfigSaveError::UnsafeExistingPath); + } + if metadata.len() > max_bytes as u64 { + return Err(UserConfigSaveError::ExistingFileTooLarge); + } + + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(path) + .map_err(|_| UserConfigSaveError::ReadFailed)?; + let opened = file + .metadata() + .map_err(|_| UserConfigSaveError::ReadFailed)?; + if !opened.is_file() { + return Err(UserConfigSaveError::UnsafeExistingPath); + } + let mut bytes = Vec::with_capacity(opened.len().min(max_bytes as u64) as usize); + file.take((max_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| UserConfigSaveError::ReadFailed)?; + if bytes.len() > max_bytes { + return Err(UserConfigSaveError::ExistingFileTooLarge); + } + Ok(Some(ExistingUserFile { + bytes, + identity: UserFileIdentity::from_metadata(&opened), + })) +} + +fn user_config_lock_path(path: &Path) -> Result { + let parent = path + .parent() + .ok_or(UserConfigSaveError::CreateDirectoryFailed)?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or(UserConfigSaveError::CreateTemporaryFileFailed)?; + Ok(parent.join(format!(".{name}.lock"))) +} + +fn open_user_config_lock(path: &Path) -> Result { + let lock_path = user_config_lock_path(path)?; + if fs::symlink_metadata(&lock_path) + .is_ok_and(|metadata| metadata.file_type().is_symlink() || !metadata.is_file()) + { + return Err(UserConfigSaveError::UnsafeExistingPath); + } + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let lock = options + .open(lock_path) + .map_err(|_| UserConfigSaveError::CreateTemporaryFileFailed)?; + let metadata = lock + .metadata() + .map_err(|_| UserConfigSaveError::ReadFailed)?; + if !metadata.is_file() { + return Err(UserConfigSaveError::UnsafeExistingPath); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(UserConfigSaveError::UnsafeExistingPath); + } + } + apply_secure_user_file_metadata(&lock).map_err(|_| UserConfigSaveError::WriteFailed)?; + Ok(lock) +} + +fn acquire_user_config_lock(path: &Path) -> Result { + let lock = open_user_config_lock(path)?; + lock.lock() + .map_err(|_| UserConfigSaveError::CreateTemporaryFileFailed)?; + Ok(lock) +} + +#[cfg(test)] +fn try_acquire_user_config_lock(path: &Path) -> Result { + let lock = open_user_config_lock(path)?; + lock.try_lock() + .map_err(|_| UserConfigSaveError::ConcurrentModification)?; + Ok(lock) +} + +fn open_unique_user_temp(path: &Path) -> Result<(PathBuf, File), UserConfigSaveError> { + let parent = path + .parent() + .ok_or(UserConfigSaveError::CreateDirectoryFailed)?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or(UserConfigSaveError::CreateTemporaryFileFailed)?; + for _ in 0..64 { + let counter = USER_FILE_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let temp_path = parent.join(format!(".{name}.tmp-{}-{counter}", std::process::id(),)); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&temp_path) { + Ok(file) => return Ok((temp_path, file)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => return Err(UserConfigSaveError::CreateTemporaryFileFailed), + } + } + Err(UserConfigSaveError::CreateTemporaryFileFailed) +} + +struct UserTempGuard { + path: PathBuf, + armed: bool, +} + +impl UserTempGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn path(&self) -> &Path { + &self.path + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for UserTempGuard { + fn drop(&mut self) { + if self.armed { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn apply_secure_user_file_metadata(file: &File) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + clear_user_file_acl(file) +} + +#[cfg(not(unix))] +fn apply_secure_user_file_metadata(_file: &File) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(target_os = "macos")] +fn clear_user_file_acl(file: &File) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + unsafe { + let acl = acl_init(0); + if acl.is_null() { + return Err(std::io::Error::last_os_error()); + } + let result = acl_set_fd_np(file.as_raw_fd(), acl, MACOS_ACL_TYPE_EXTENDED); + let set_error = (result != 0).then(std::io::Error::last_os_error); + let free_result = acl_free(acl); + if let Some(error) = set_error { + return Err(error); + } + if free_result != 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn clear_user_file_acl(file: &File) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let name = c"system.posix_acl_access"; + let result = unsafe { libc::fremovexattr(file.as_raw_fd(), name.as_ptr()) }; + if result == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + match error.raw_os_error() { + Some(libc::ENODATA) | Some(libc::ENOTSUP) => Ok(()), + _ => Err(error), + } +} + +#[cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))] +fn clear_user_file_acl(_file: &File) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "secure ACL reset is unsupported", + )) +} + +trait AtomicUserFileOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()>; + fn after_prevalidation(&self, _path: &Path) -> std::io::Result<()> { + Ok(()) + } + fn matches_expected( + &self, + path: &Path, + expected: &ExpectedUserFile, + ) -> Result { + user_file_matches_expected(path, expected) + } + fn reread_temp(&self, path: &Path) -> Result, UserConfigSaveError> { + read_optional_regular_file(path, MAX_USER_CONFIG_BYTES) + } + fn exchange(&self, from: &Path, to: &Path) -> std::io::Result<()> { + platform_exchange_user_file(from, to) + } + fn install_missing(&self, from: &Path, to: &Path) -> std::io::Result<()> { + platform_install_missing_user_file(from, to) + } + fn after_missing_install(&self, _from: &Path, _to: &Path) -> std::io::Result<()> { + Ok(()) + } + fn sync_parent(&self, _parent: &Path) -> std::io::Result<()> { + Ok(()) + } + fn remove_temp(&self, path: &Path) -> std::io::Result<()> { + fs::remove_file(path) + } +} + +struct RealAtomicUserFileOps; + +impl AtomicUserFileOps for RealAtomicUserFileOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + file.write_all(bytes)?; + file.sync_all() + } + + fn sync_parent(&self, parent: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + return File::open(parent)?.sync_all(); + } + #[cfg(not(unix))] + { + let _ = parent; + Ok(()) + } + } +} + +#[cfg(target_os = "macos")] +fn platform_exchange_user_file(from: &Path, to: &Path) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let from = CString::new(from.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let to = CString::new(to.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let result = unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_SWAP) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(target_os = "linux")] +fn platform_exchange_user_file(from: &Path, to: &Path) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let from = CString::new(from.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let to = CString::new(to.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_EXCHANGE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(target_os = "macos")] +fn platform_install_missing_user_file(from: &Path, to: &Path) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let from = CString::new(from.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let to = CString::new(to.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let result = unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_EXCL) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(target_os = "linux")] +fn platform_install_missing_user_file(from: &Path, to: &Path) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let from = CString::new(from.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let to = CString::new(to.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::InvalidInput))?; + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + from.as_ptr(), + libc::AT_FDCWD, + to.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn platform_exchange_user_file(_from: &Path, _to: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic exchange is unsupported", + )) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn platform_install_missing_user_file(_from: &Path, _to: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "atomic no-replace move is unsupported", + )) +} + +fn atomic_replace_user_file_with_ops( + path: &Path, + bytes: &[u8], + expected: &ExpectedUserFile, + operations: &impl AtomicUserFileOps, +) -> Result<(), UserConfigSaveError> { + let parent = path + .parent() + .ok_or(UserConfigSaveError::CreateDirectoryFailed)?; + fs::create_dir_all(parent).map_err(|_| UserConfigSaveError::CreateDirectoryFailed)?; + let (temp_path, mut temp) = open_unique_user_temp(path)?; + let mut temp_guard = UserTempGuard::new(temp_path); + let write_result = (|| { + apply_secure_user_file_metadata(&temp).map_err(|_| UserConfigSaveError::WriteFailed)?; + operations + .write_and_sync(&mut temp, bytes) + .map_err(|_| UserConfigSaveError::WriteFailed)?; + Ok(()) + })(); + drop(temp); + if let Err(error) = write_result { + return Err(error); + } + match operations.matches_expected(path, expected) { + Ok(true) => {} + Ok(false) => return Err(UserConfigSaveError::ConcurrentModification), + Err(error) => return Err(error), + } + if operations.after_prevalidation(path).is_err() { + return Err(UserConfigSaveError::ReplaceFailed); + } + match expected { + ExpectedUserFile::Missing => { + if let Err(error) = operations.install_missing(temp_guard.path(), path) { + if error.kind() == std::io::ErrorKind::AlreadyExists { + return Err(UserConfigSaveError::ConcurrentModification); + } + return Err(UserConfigSaveError::ReplaceFailed); + } + temp_guard.disarm(); + if operations + .after_missing_install(temp_guard.path(), path) + .is_err() + { + let _ = operations.sync_parent(parent); + return Err(UserConfigSaveError::ReplaceFailed); + } + } + ExpectedUserFile::Existing(_) => { + let ours = match operations.reread_temp(temp_guard.path()) { + Ok(Some(ours)) => ExpectedUserFile::Existing(ours), + Ok(None) => return Err(UserConfigSaveError::ReplaceFailed), + Err(error) => return Err(error), + }; + if operations.exchange(temp_guard.path(), path).is_err() { + return Err(UserConfigSaveError::ReplaceFailed); + } + match operations.matches_expected(temp_guard.path(), expected) { + Ok(true) => { + if operations.remove_temp(temp_guard.path()).is_err() { + temp_guard.disarm(); + let _ = operations.sync_parent(parent); + return Err(UserConfigSaveError::ReplaceFailed); + } + temp_guard.disarm(); + } + Ok(false) => { + if operations.exchange(temp_guard.path(), path).is_err() { + temp_guard.disarm(); + let _ = operations.sync_parent(parent); + return Err(UserConfigSaveError::RollbackFailed); + } + if !matches!( + user_file_matches_expected(temp_guard.path(), &ours), + Ok(true) + ) { + temp_guard.disarm(); + let _ = operations.sync_parent(parent); + return Err(UserConfigSaveError::RollbackFailed); + } + if operations.remove_temp(temp_guard.path()).is_err() { + temp_guard.disarm(); + let _ = operations.sync_parent(parent); + return Err(UserConfigSaveError::ReplaceFailed); + } + temp_guard.disarm(); + operations + .sync_parent(parent) + .map_err(|_| UserConfigSaveError::ReplaceFailed)?; + return Err(UserConfigSaveError::ConcurrentModification); + } + Err(error) => { + if operations.exchange(temp_guard.path(), path).is_err() { + temp_guard.disarm(); + let _ = operations.sync_parent(parent); + return Err(UserConfigSaveError::RollbackFailed); + } + if !matches!( + user_file_matches_expected(temp_guard.path(), &ours), + Ok(true) + ) { + temp_guard.disarm(); + let _ = operations.sync_parent(parent); + return Err(UserConfigSaveError::RollbackFailed); + } + if operations.remove_temp(temp_guard.path()).is_err() { + temp_guard.disarm(); + let _ = operations.sync_parent(parent); + return Err(UserConfigSaveError::ReplaceFailed); + } + temp_guard.disarm(); + operations + .sync_parent(parent) + .map_err(|_| UserConfigSaveError::ReplaceFailed)?; + return Err(error); + } + } + } + } + operations + .sync_parent(parent) + .map_err(|_| UserConfigSaveError::ReplaceFailed)?; + Ok(()) +} + +fn user_file_matches_expected( + path: &Path, + expected: &ExpectedUserFile, +) -> Result { + match read_optional_regular_file(path, MAX_USER_CONFIG_BYTES) { + Ok(None) => Ok(matches!(expected, ExpectedUserFile::Missing)), + Ok(Some(current)) => Ok(matches!( + expected, + ExpectedUserFile::Existing(previous) if previous == ¤t + )), + Err(UserConfigSaveError::UnsafeExistingPath) + | Err(UserConfigSaveError::ExistingFileTooLarge) => Ok(false), + Err(error) => Err(error), + } +} + +fn save_user_preferences_at( + path: &Path, + patch: &UserPreferencePatch, +) -> Result<(), UserConfigSaveError> { + save_user_preferences_at_with_ops(path, patch, &RealAtomicUserFileOps) +} + +fn save_user_preferences_at_with_ops( + path: &Path, + patch: &UserPreferencePatch, + operations: &impl AtomicUserFileOps, +) -> Result<(), UserConfigSaveError> { + let parent = path + .parent() + .ok_or(UserConfigSaveError::CreateDirectoryFailed)?; + fs::create_dir_all(parent).map_err(|_| UserConfigSaveError::CreateDirectoryFailed)?; + let _lock = acquire_user_config_lock(path)?; + let existing = read_optional_regular_file(path, MAX_USER_CONFIG_BYTES)?; + let (source, expected) = match existing { + Some(existing) => { + let source = String::from_utf8(existing.bytes.clone()) + .map_err(|_| UserConfigSaveError::InvalidExistingContent)?; + (source, ExpectedUserFile::Existing(existing)) + } + None => (String::new(), ExpectedUserFile::Missing), + }; + let output = patch_user_preferences(&source, patch)?; + atomic_replace_user_file_with_ops(path, output.as_bytes(), &expected, operations) +} + +fn save_user_preferences_in_dir( + directory: &Path, + patch: &UserPreferencePatch, +) -> Result<(), UserConfigSaveError> { + save_user_preferences_at(&directory.join("config.toml"), patch) +} + +pub fn save_user_preferences(patch: &UserPreferencePatch) -> Result<(), UserConfigSaveError> { + let directory = config_dir().ok_or(UserConfigSaveError::ConfigDirectoryUnavailable)?; + save_user_preferences_in_dir(&directory, patch) +} + pub fn load_layered_config(cwd: &Path) -> FileConfig { let Some(dir) = config_dir() else { return load_layered_config_from_optional_paths(None, cwd); @@ -331,6 +1123,7 @@ fn merge_toml_values(base: &mut Value, overlay: Value) { fn remove_project_denied_fields(value: &mut Value) { if let Some(table) = value.as_table_mut() { + table.remove("provider"); table.remove("api_key"); table.remove("base_url"); table.remove("hooks"); @@ -368,76 +1161,1272 @@ fn fold_legacy_workflow_settings_into_value(value: &mut Value) { } } - if !workflows_table.contains_key("workflowKeywordTriggerEnabled") { - if let Some(keyword_enabled) = legacy_keyword { - workflows_table.insert( - "workflowKeywordTriggerEnabled".to_string(), - Value::Boolean(keyword_enabled), + if !workflows_table.contains_key("workflowKeywordTriggerEnabled") { + if let Some(keyword_enabled) = legacy_keyword { + workflows_table.insert( + "workflowKeywordTriggerEnabled".to_string(), + Value::Boolean(keyword_enabled), + ); + } + } + + root.remove("disableWorkflows"); + root.remove("enableWorkflows"); + root.remove("workflowKeywordTriggerEnabled"); +} + +pub fn apply_override_layers( + mut config: FileConfig, + env: ConfigOverrides, + cli: ConfigOverrides, +) -> FileConfig { + apply_overrides(&mut config, env); + apply_overrides(&mut config, cli); + config +} + +fn apply_overrides(config: &mut FileConfig, overrides: ConfigOverrides) { + if let Some(provider) = overrides.provider { + config.provider = provider; + } + if overrides.model.is_some() { + config.model = overrides.model; + } + if overrides.mode.is_some() { + config.mode = overrides.mode; + } + if overrides.api_key.is_some() { + config.api_key = overrides.api_key; + } + if overrides.base_url.is_some() { + config.base_url = overrides.base_url; + } + if let Some(reasoning_effort) = overrides.reasoning_effort { + config.reasoning_effort = reasoning_effort; + } +} + +fn load_auth_key(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + let map: HashMap = serde_json::from_str(&content).ok()?; + map.get("DEEPSEEK_API_KEY").cloned() +} + +fn patch_api_key(source: Option<&str>, api_key: &str) -> Result, UserConfigSaveError> { + if api_key.len() > MAX_AUTH_FILE_BYTES { + return Err(UserConfigSaveError::ExistingFileTooLarge); + } + let mut map = if let Some(source) = source { + serde_json::from_str::>(source) + .map_err(|_| UserConfigSaveError::InvalidExistingContent)? + } else { + BTreeMap::new() + }; + map.insert("DEEPSEEK_API_KEY".to_string(), api_key.to_string()); + let output = serde_json::to_vec_pretty(&map).map_err(|_| UserConfigSaveError::WriteFailed)?; + if output.len() > MAX_AUTH_FILE_BYTES { + return Err(UserConfigSaveError::ExistingFileTooLarge); + } + Ok(output) +} + +fn save_api_key_at_with_ops( + path: &Path, + api_key: &str, + operations: &impl AtomicUserFileOps, +) -> Result<(), UserConfigSaveError> { + let parent = path + .parent() + .ok_or(UserConfigSaveError::CreateDirectoryFailed)?; + fs::create_dir_all(parent).map_err(|_| UserConfigSaveError::CreateDirectoryFailed)?; + let _lock = acquire_user_config_lock(path)?; + let existing = read_optional_regular_file(path, MAX_AUTH_FILE_BYTES)?; + let (source, expected) = match existing { + Some(existing) => { + let source = String::from_utf8(existing.bytes.clone()) + .map_err(|_| UserConfigSaveError::InvalidExistingContent)?; + (Some(source), ExpectedUserFile::Existing(existing)) + } + None => (None, ExpectedUserFile::Missing), + }; + let output = patch_api_key(source.as_deref(), api_key)?; + atomic_replace_user_file_with_ops(path, &output, &expected, operations) +} + +fn save_api_key_at(path: &Path, api_key: &str) -> Result<(), UserConfigSaveError> { + save_api_key_at_with_ops(path, api_key, &RealAtomicUserFileOps) +} + +pub fn save_api_key(api_key: &str) -> Result<(), UserConfigSaveError> { + let directory = config_dir().ok_or(UserConfigSaveError::ConfigDirectoryUnavailable)?; + save_api_key_at(&directory.join("auth.json"), api_key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ProviderKind; + + const PUBLIC_PREFERENCE_SAVE_CHILD_ENV: &str = "ORCA_TEST_PUBLIC_PREFERENCE_SAVE_CHILD"; + const PUBLIC_AUTH_SAVE_CHILD_ENV: &str = "ORCA_TEST_PUBLIC_AUTH_SAVE_CHILD"; + const USER_CONFIG_LOCK_CHILD_ENV: &str = "ORCA_TEST_USER_CONFIG_LOCK_CHILD"; + + fn public_preference_save_child_mode() -> bool { + std::env::var_os(PUBLIC_PREFERENCE_SAVE_CHILD_ENV).is_some() + } + + fn public_preference_save_child_command(directory: &Path) -> std::process::Command { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .arg("config::file::tests::public_preference_entrypoint_uses_isolated_orca_home") + .arg("--exact") + .arg("--nocapture") + .env(PUBLIC_PREFERENCE_SAVE_CHILD_ENV, "1") + .env(ORCA_HOME_ENV, directory); + command + } + + fn public_auth_save_child_mode() -> bool { + std::env::var_os(PUBLIC_AUTH_SAVE_CHILD_ENV).is_some() + } + + fn public_auth_save_child_command(directory: &Path, test_name: &str) -> std::process::Command { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .arg(test_name) + .arg("--exact") + .arg("--nocapture") + .env(PUBLIC_AUTH_SAVE_CHILD_ENV, "1") + .env(ORCA_HOME_ENV, directory); + command + } + + #[test] + fn user_config_lock_coordinates_across_processes() { + if std::env::var_os(USER_CONFIG_LOCK_CHILD_ENV).is_some() { + let path = PathBuf::from(std::env::var_os(ORCA_HOME_ENV).unwrap()).join("config.toml"); + let _lock = acquire_user_config_lock(&path).unwrap(); + println!("LOCKED"); + std::io::stdout().flush().unwrap(); + let mut release = [0_u8; 1]; + std::io::stdin().read_exact(&mut release).unwrap(); + return; + } + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .arg("config::file::tests::user_config_lock_coordinates_across_processes") + .arg("--exact") + .arg("--nocapture") + .env(USER_CONFIG_LOCK_CHILD_ENV, "1") + .env(ORCA_HOME_ENV, directory.path()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap()); + let mut line = String::new(); + loop { + line.clear(); + std::io::BufRead::read_line(&mut stdout, &mut line).unwrap(); + assert!(!line.is_empty()); + if line.trim() == "LOCKED" { + break; + } + } + + assert_eq!( + try_acquire_user_config_lock(&path).unwrap_err(), + UserConfigSaveError::ConcurrentModification, + ); + child.stdin.take().unwrap().write_all(b"x").unwrap(); + assert!(child.wait().unwrap().success()); + assert!(try_acquire_user_config_lock(&path).is_ok()); + } + + struct FailingAtomicUserFileOps { + fail_write: bool, + fail_rename: bool, + fail_parent_sync: bool, + parent_sync_calls: std::cell::Cell, + } + + impl AtomicUserFileOps for FailingAtomicUserFileOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + if self.fail_write { + file.write_all(b"partial")?; + return Err(std::io::Error::other("injected write failure")); + } + file.write_all(bytes)?; + file.sync_all() + } + + fn exchange(&self, from: &Path, to: &Path) -> std::io::Result<()> { + if self.fail_rename { + return Err(std::io::Error::other("injected exchange failure")); + } + platform_exchange_user_file(from, to) + } + + fn sync_parent(&self, parent: &Path) -> std::io::Result<()> { + self.parent_sync_calls.set(self.parent_sync_calls.get() + 1); + if self.fail_parent_sync { + return Err(std::io::Error::other("injected parent sync failure")); + } + File::open(parent)?.sync_all() + } + } + + enum ConcurrentMutation { + ReplaceContents(&'static [u8]), + CreateMissing(&'static [u8]), + RevalidationError, + #[cfg(unix)] + ReplaceWithSocket, + } + + struct ConcurrentMutationOps { + mutation: ConcurrentMutation, + } + + impl AtomicUserFileOps for ConcurrentMutationOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + file.write_all(bytes)?; + file.sync_all() + } + + fn after_prevalidation(&self, path: &Path) -> std::io::Result<()> { + match self.mutation { + ConcurrentMutation::ReplaceContents(bytes) => std::fs::write(path, bytes), + ConcurrentMutation::CreateMissing(bytes) => std::fs::write(path, bytes), + ConcurrentMutation::RevalidationError => Ok(()), + #[cfg(unix)] + ConcurrentMutation::ReplaceWithSocket => { + use std::os::unix::net::UnixListener; + + std::fs::remove_file(path)?; + let _listener = UnixListener::bind(path)?; + Ok(()) + } + } + } + + fn matches_expected( + &self, + path: &Path, + expected: &ExpectedUserFile, + ) -> Result { + if matches!(self.mutation, ConcurrentMutation::RevalidationError) { + return Err(UserConfigSaveError::ReadFailed); + } + user_file_matches_expected(path, expected) + } + } + + struct RollbackFailureOps { + exchange_calls: std::cell::Cell, + } + + impl AtomicUserFileOps for RollbackFailureOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + file.write_all(bytes)?; + file.sync_all() + } + + fn after_prevalidation(&self, path: &Path) -> std::io::Result<()> { + std::fs::write(path, b"theme = \"catppuccin\"\n") + } + + fn exchange(&self, from: &Path, to: &Path) -> std::io::Result<()> { + let call = self.exchange_calls.get(); + self.exchange_calls.set(call + 1); + if call == 1 { + return Err(std::io::Error::other("injected rollback failure")); + } + platform_exchange_user_file(from, to) + } + } + + struct CleanupFailureOps; + + impl AtomicUserFileOps for CleanupFailureOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + file.write_all(bytes)?; + file.sync_all() + } + + fn remove_temp(&self, _path: &Path) -> std::io::Result<()> { + Err(std::io::Error::other("injected cleanup failure")) + } + } + + struct PostExchangeMutationOps { + exchange_calls: std::cell::Cell, + } + + impl AtomicUserFileOps for PostExchangeMutationOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + file.write_all(bytes)?; + file.sync_all() + } + + fn after_prevalidation(&self, path: &Path) -> std::io::Result<()> { + std::fs::write(path, b"theme = \"catppuccin\"\n") + } + + fn exchange(&self, from: &Path, to: &Path) -> std::io::Result<()> { + platform_exchange_user_file(from, to)?; + let call = self.exchange_calls.get(); + self.exchange_calls.set(call + 1); + if call == 0 { + std::fs::write(to, b"theme = \"post-exchange\"\n")?; + } + Ok(()) + } + } + + struct MissingInstallObservationOps { + temp_exists_after_install: std::cell::Cell, + target_link_count_after_install: std::cell::Cell, + } + + impl AtomicUserFileOps for MissingInstallObservationOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + file.write_all(bytes)?; + file.sync_all() + } + + fn after_missing_install(&self, from: &Path, to: &Path) -> std::io::Result<()> { + self.temp_exists_after_install.set(from.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + self.target_link_count_after_install + .set(std::fs::metadata(to)?.nlink()); + } + Ok(()) + } + } + + struct MissingTempRereadOps; + + impl AtomicUserFileOps for MissingTempRereadOps { + fn write_and_sync(&self, file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + file.write_all(bytes)?; + file.sync_all() + } + + fn reread_temp( + &self, + _path: &Path, + ) -> Result, UserConfigSaveError> { + Ok(None) + } + } + + struct SecretBearingWriteFailureOps; + + impl AtomicUserFileOps for SecretBearingWriteFailureOps { + fn write_and_sync(&self, _file: &mut File, bytes: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::other( + String::from_utf8_lossy(bytes).into_owned(), + )) + } + } + + fn user_temp_files(directory: &Path) -> Vec { + std::fs::read_dir(directory) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(".config.toml.tmp-")) + }) + .collect() + } + + fn auth_temp_files(directory: &Path) -> Vec { + std::fs::read_dir(directory) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(".auth.json.tmp-")) + }) + .collect() + } + + #[cfg(target_os = "macos")] + fn set_inheritable_read_acl(directory: &Path) { + use std::ffi::CString; + use std::os::fd::AsRawFd; + + let directory = File::open(directory).unwrap(); + let text = CString::new( + "!#acl 1\ngroup:ABCDEFAB-CDEF-ABCD-EFAB-CDEF0000000C:everyone:12:allow,file_inherit:read\n", + ) + .unwrap(); + unsafe { + let acl = acl_from_text(text.as_ptr()); + assert!(!acl.is_null()); + assert_eq!( + acl_set_fd_np(directory.as_raw_fd(), acl, MACOS_ACL_TYPE_EXTENDED), + 0, + ); + assert_eq!(acl_free(acl), 0); + } + } + + #[cfg(target_os = "macos")] + fn has_extended_acl(path: &Path) -> bool { + use std::os::fd::AsRawFd; + + let file = File::open(path).unwrap(); + unsafe { + let acl = acl_get_fd_np(file.as_raw_fd(), MACOS_ACL_TYPE_EXTENDED); + if acl.is_null() { + return false; + } + let mut entry = std::ptr::null_mut(); + let has_entry = acl_get_entry(acl, MACOS_ACL_FIRST_ENTRY, &mut entry) == 0; + assert_eq!(acl_free(acl), 0); + has_entry + } + } + + #[test] + fn atomic_writer_creates_unique_temp_in_target_directory() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("config.toml"); + let (temp_path, temp) = open_unique_user_temp(&target).unwrap(); + + assert_eq!(temp_path.parent(), Some(directory.path())); + assert!( + temp_path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(".config.toml.tmp-") + ); + assert_eq!( + OpenOptions::new() + .create_new(true) + .write(true) + .open(&temp_path) + .unwrap_err() + .kind(), + std::io::ErrorKind::AlreadyExists, + ); + + drop(temp); + std::fs::remove_file(temp_path).unwrap(); + } + + #[cfg(unix)] + #[test] + fn atomic_writer_creates_new_config_with_mode_0600() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + save_user_preferences_at(&path, &patch).unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[cfg(unix)] + #[test] + fn atomic_writer_restricts_existing_permissions_to_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "theme = \"light\"\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + save_user_preferences_at(&path, &patch).unwrap(); + + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn atomic_writer_removes_inherited_extended_acl() { + let directory = tempfile::tempdir().unwrap(); + set_inheritable_read_acl(directory.path()); + let path = directory.path().join("config.toml"); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + save_user_preferences_at(&path, &patch).unwrap(); + + assert!(!has_extended_acl(&path)); + } + + #[cfg(target_os = "macos")] + #[test] + fn user_config_lock_removes_inherited_extended_acl() { + let directory = tempfile::tempdir().unwrap(); + set_inheritable_read_acl(directory.path()); + let path = directory.path().join("config.toml"); + + drop(acquire_user_config_lock(&path).unwrap()); + + assert!(!has_extended_acl(&user_config_lock_path(&path).unwrap())); + } + + #[cfg(unix)] + #[test] + fn user_config_lock_rejects_hard_link() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let victim = directory.path().join("victim"); + std::fs::write(&victim, b"do not chmod").unwrap(); + let lock_path = user_config_lock_path(&path).unwrap(); + std::fs::hard_link(&victim, &lock_path).unwrap(); + + assert_eq!( + acquire_user_config_lock(&path).unwrap_err(), + UserConfigSaveError::UnsafeExistingPath, + ); + assert_eq!(std::fs::read(victim).unwrap(), b"do not chmod"); + } + + #[test] + fn atomic_writer_cleans_temp_after_write_failure() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let original = b"theme = \"light\"\n"; + std::fs::write(&path, original).unwrap(); + let expected = ExpectedUserFile::Existing( + read_optional_regular_file(&path, MAX_USER_CONFIG_BYTES) + .unwrap() + .unwrap(), + ); + let operations = FailingAtomicUserFileOps { + fail_write: true, + fail_rename: false, + fail_parent_sync: false, + parent_sync_calls: std::cell::Cell::new(0), + }; + + assert_eq!( + atomic_replace_user_file_with_ops( + &path, + b"model = \"auto\"\n", + &expected, + &operations, + ) + .unwrap_err(), + UserConfigSaveError::WriteFailed, + ); + assert!(user_temp_files(directory.path()).is_empty()); + assert_eq!(std::fs::read(path).unwrap(), original); + } + + #[test] + fn atomic_writer_cleans_temp_after_rename_failure() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let original = b"theme = \"light\"\n"; + std::fs::write(&path, original).unwrap(); + let expected = ExpectedUserFile::Existing( + read_optional_regular_file(&path, MAX_USER_CONFIG_BYTES) + .unwrap() + .unwrap(), + ); + let operations = FailingAtomicUserFileOps { + fail_write: false, + fail_rename: true, + fail_parent_sync: false, + parent_sync_calls: std::cell::Cell::new(0), + }; + + assert_eq!( + atomic_replace_user_file_with_ops( + &path, + b"model = \"auto\"\n", + &expected, + &operations, + ) + .unwrap_err(), + UserConfigSaveError::ReplaceFailed, + ); + assert!(user_temp_files(directory.path()).is_empty()); + assert_eq!(std::fs::read(path).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn atomic_writer_syncs_parent_directory_after_replace() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "theme = \"light\"\n").unwrap(); + let expected = ExpectedUserFile::Existing( + read_optional_regular_file(&path, MAX_USER_CONFIG_BYTES) + .unwrap() + .unwrap(), + ); + let operations = FailingAtomicUserFileOps { + fail_write: false, + fail_rename: false, + fail_parent_sync: false, + parent_sync_calls: std::cell::Cell::new(0), + }; + + atomic_replace_user_file_with_ops(&path, b"theme = \"dark\"\n", &expected, &operations) + .unwrap(); + + assert_eq!(operations.parent_sync_calls.get(), 1); + assert_eq!(std::fs::read_to_string(path).unwrap(), "theme = \"dark\"\n"); + } + + #[cfg(unix)] + #[test] + fn atomic_writer_parent_sync_failure_keeps_visible_replacement() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "theme = \"light\"\n").unwrap(); + let expected = ExpectedUserFile::Existing( + read_optional_regular_file(&path, MAX_USER_CONFIG_BYTES) + .unwrap() + .unwrap(), + ); + let operations = FailingAtomicUserFileOps { + fail_write: false, + fail_rename: false, + fail_parent_sync: true, + parent_sync_calls: std::cell::Cell::new(0), + }; + + assert_eq!( + atomic_replace_user_file_with_ops( + &path, + b"theme = \"dark\"\n", + &expected, + &operations, + ) + .unwrap_err(), + UserConfigSaveError::ReplaceFailed, + ); + assert_eq!(operations.parent_sync_calls.get(), 1); + assert_eq!(std::fs::read_to_string(path).unwrap(), "theme = \"dark\"\n"); + assert!(user_temp_files(directory.path()).is_empty()); + } + + #[test] + fn preference_writer_rejects_concurrent_modification_without_overwrite() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "theme = \"light\"\n").unwrap(); + let concurrent = b"theme = \"catppuccin\"\n"; + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + let operations = ConcurrentMutationOps { + mutation: ConcurrentMutation::ReplaceContents(concurrent), + }; + + assert_eq!( + save_user_preferences_at_with_ops(&path, &patch, &operations).unwrap_err(), + UserConfigSaveError::ConcurrentModification, + ); + assert_eq!(std::fs::read(&path).unwrap(), concurrent); + assert!(user_temp_files(directory.path()).is_empty()); + assert!(acquire_user_config_lock(&path).is_ok()); + } + + #[test] + fn preference_writer_rejects_concurrent_creation_of_missing_config() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let concurrent = b"unknown = \"created concurrently\"\n"; + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + let operations = ConcurrentMutationOps { + mutation: ConcurrentMutation::CreateMissing(concurrent), + }; + + assert_eq!( + save_user_preferences_at_with_ops(&path, &patch, &operations).unwrap_err(), + UserConfigSaveError::ConcurrentModification, + ); + assert_eq!(std::fs::read(&path).unwrap(), concurrent); + assert!(user_temp_files(directory.path()).is_empty()); + assert!(acquire_user_config_lock(&path).is_ok()); + } + + #[test] + fn preference_writer_cleans_temp_when_revalidation_errors() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "theme = \"light\"\n").unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + let operations = ConcurrentMutationOps { + mutation: ConcurrentMutation::RevalidationError, + }; + + assert_eq!( + save_user_preferences_at_with_ops(&path, &patch, &operations).unwrap_err(), + UserConfigSaveError::ReadFailed, + ); + assert!(user_temp_files(directory.path()).is_empty()); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "theme = \"light\"\n" + ); + assert!(acquire_user_config_lock(&path).is_ok()); + } + + #[cfg(unix)] + #[test] + fn preference_writer_rejects_concurrent_special_file_replacement() { + use std::os::unix::fs::FileTypeExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "theme = \"light\"\n").unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + let operations = ConcurrentMutationOps { + mutation: ConcurrentMutation::ReplaceWithSocket, + }; + + assert_eq!( + save_user_preferences_at_with_ops(&path, &patch, &operations).unwrap_err(), + UserConfigSaveError::ConcurrentModification, + ); + assert!( + std::fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_socket() + ); + assert!(user_temp_files(directory.path()).is_empty()); + assert!(acquire_user_config_lock(&path).is_ok()); + } + + #[test] + fn preference_writer_rollback_failure_keeps_recoverable_old_target_artifact() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "theme = \"light\"\n").unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + let operations = RollbackFailureOps { + exchange_calls: std::cell::Cell::new(0), + }; + + assert_eq!( + save_user_preferences_at_with_ops(&path, &patch, &operations).unwrap_err(), + UserConfigSaveError::RollbackFailed, + ); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("theme = \"dark\"") + ); + let artifacts = user_temp_files(directory.path()); + assert_eq!(artifacts.len(), 1); + assert_eq!( + std::fs::read_to_string(&artifacts[0]).unwrap(), + "theme = \"catppuccin\"\n" + ); + assert!(acquire_user_config_lock(&path).is_ok()); + } + + #[test] + fn atomic_exchange_cleanup_failure_is_not_reported_as_success() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let original = "theme = \"light\"\n"; + std::fs::write(&path, original).unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + assert_eq!( + save_user_preferences_at_with_ops(&path, &patch, &CleanupFailureOps).unwrap_err(), + UserConfigSaveError::ReplaceFailed, + ); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("theme = \"dark\"") + ); + let artifacts = user_temp_files(directory.path()); + assert_eq!(artifacts.len(), 1); + assert_eq!(std::fs::read_to_string(&artifacts[0]).unwrap(), original); + } + + #[test] + fn rollback_preserves_post_exchange_concurrent_content_as_artifact() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "theme = \"light\"\n").unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + let operations = PostExchangeMutationOps { + exchange_calls: std::cell::Cell::new(0), + }; + + assert_eq!( + save_user_preferences_at_with_ops(&path, &patch, &operations).unwrap_err(), + UserConfigSaveError::RollbackFailed, + ); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "theme = \"catppuccin\"\n" + ); + let artifacts = user_temp_files(directory.path()); + assert_eq!(artifacts.len(), 1); + assert_eq!( + std::fs::read_to_string(&artifacts[0]).unwrap(), + "theme = \"post-exchange\"\n" + ); + } + + #[test] + fn atomic_missing_install_moves_temp_without_residual_link() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + save_user_preferences_at(&path, &patch).unwrap(); + + assert!(path.is_file()); + assert!(user_temp_files(directory.path()).is_empty()); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(std::fs::metadata(path).unwrap().nlink(), 1); + } + } + + #[test] + fn atomic_missing_install_has_no_transient_second_link_after_move() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + let operations = MissingInstallObservationOps { + temp_exists_after_install: std::cell::Cell::new(true), + target_link_count_after_install: std::cell::Cell::new(0), + }; + + save_user_preferences_at_with_ops(&path, &patch, &operations).unwrap(); + + assert!(!operations.temp_exists_after_install.get()); + #[cfg(unix)] + assert_eq!(operations.target_link_count_after_install.get(), 1); + } + + #[test] + fn preference_patch_accepts_only_production_provider_and_known_models() { + assert!(UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark,).is_ok()); + assert_eq!( + UserPreferencePatch::new(ProviderKind::Mock, "auto", ThemeName::Dark,).unwrap_err(), + UserPreferenceValidationError::UnsupportedProvider, + ); + assert_eq!( + UserPreferencePatch::new(ProviderKind::DeepSeek, "unknown", ThemeName::Dark,) + .unwrap_err(), + UserPreferenceValidationError::UnsupportedModel, + ); + } + + #[test] + fn preference_patch_preserves_comments_unknown_keys_and_nested_tables() { + let source = "\ +# keep me +unknown = \"value\" +model = \"deepseek-v4-flash\" + +[tools] +max_read_parallel = 7 +"; + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Solarized).unwrap(); + let output = patch_user_preferences(source, &patch).unwrap(); + + assert!(output.contains("# keep me")); + assert!(output.contains("unknown = \"value\"")); + assert!(output.contains("[tools]")); + assert!(output.contains("max_read_parallel = 7")); + assert!(output.contains("provider = \"deep-seek\"")); + assert!(output.contains("model = \"auto\"")); + assert!(output.contains("theme = \"solarized\"")); + assert!(!output.contains("api_key")); + } + + #[test] + fn preference_patch_preserves_preference_value_decorations_and_is_idempotent() { + let source = "provider= \"mock\" # provider note\nmodel =\t\"deepseek-v4-flash\"\t# model note\ntheme = \"light\" # theme note\n"; + let patch = UserPreferencePatch::new( + ProviderKind::DeepSeek, + "deepseek-v4-pro", + ThemeName::Solarized, + ) + .unwrap(); + + let once = patch_user_preferences(source, &patch).unwrap(); + let twice = patch_user_preferences(&once, &patch).unwrap(); + + assert!(once.contains("provider= \"deep-seek\" # provider note")); + assert!(once.contains("model =\t\"deepseek-v4-pro\"\t# model note")); + assert!(once.contains("theme = \"solarized\" # theme note")); + assert_eq!(twice, once); + } + + #[test] + fn invalid_existing_config_is_not_replaced() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let original = b"this is not [valid toml {{{"; + std::fs::write(&path, original).unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + assert_eq!( + save_user_preferences_at(&path, &patch).unwrap_err(), + UserConfigSaveError::InvalidExistingContent, + ); + assert_eq!(std::fs::read(path).unwrap(), original); + } + + #[test] + fn preference_writer_rejects_unsafe_and_oversized_existing_paths() { + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let dir_path = directory.path().join("directory"); + std::fs::create_dir(&dir_path).unwrap(); + assert_eq!( + save_user_preferences_at(&dir_path, &patch).unwrap_err(), + UserConfigSaveError::UnsafeExistingPath, + ); + + let oversized = directory.path().join("oversized.toml"); + std::fs::write(&oversized, vec![b'x'; MAX_USER_CONFIG_BYTES + 1]).unwrap(); + assert_eq!( + save_user_preferences_at(&oversized, &patch).unwrap_err(), + UserConfigSaveError::ExistingFileTooLarge, + ); + + #[cfg(unix)] + { + let target = directory.path().join("target.toml"); + std::fs::write(&target, "theme = \"dark\"").unwrap(); + let link = directory.path().join("link.toml"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert_eq!( + save_user_preferences_at(&link, &patch).unwrap_err(), + UserConfigSaveError::UnsafeExistingPath, ); + assert_eq!(std::fs::read_to_string(target).unwrap(), "theme = \"dark\""); } } - root.remove("disableWorkflows"); - root.remove("enableWorkflows"); - root.remove("workflowKeywordTriggerEnabled"); -} + #[test] + fn preference_writer_cleans_temp_when_patched_output_exceeds_limit() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let prefix = "api_key = \"legacy-secret\"\npadding = \""; + let suffix = "\"\n"; + let padding = "x".repeat(MAX_USER_CONFIG_BYTES - prefix.len() - suffix.len()); + let original = format!("{prefix}{padding}{suffix}"); + assert_eq!(original.len(), MAX_USER_CONFIG_BYTES); + std::fs::write(&path, original.as_bytes()).unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); -pub fn apply_override_layers( - mut config: FileConfig, - env: ConfigOverrides, - cli: ConfigOverrides, -) -> FileConfig { - apply_overrides(&mut config, env); - apply_overrides(&mut config, cli); - config -} + assert_eq!( + save_user_preferences_at(&path, &patch).unwrap_err(), + UserConfigSaveError::ExistingFileTooLarge, + ); + assert_eq!(std::fs::read(&path).unwrap(), original.as_bytes()); + assert!(user_temp_files(directory.path()).is_empty()); + } -fn apply_overrides(config: &mut FileConfig, overrides: ConfigOverrides) { - if overrides.model.is_some() { - config.model = overrides.model; + #[test] + fn preference_writer_cleans_temp_when_temp_reread_returns_missing() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let original = b"theme = \"light\"\n"; + std::fs::write(&path, original).unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + assert_eq!( + save_user_preferences_at_with_ops(&path, &patch, &MissingTempRereadOps).unwrap_err(), + UserConfigSaveError::ReplaceFailed, + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + assert!(user_temp_files(directory.path()).is_empty()); } - if overrides.mode.is_some() { - config.mode = overrides.mode; + + #[test] + fn preference_persistence_invalid_utf8_is_byte_identical() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let original = b"theme = \"dark\"\n\xff\xfe"; + std::fs::write(&path, original).unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + assert_eq!( + save_user_preferences_at(&path, &patch).unwrap_err(), + UserConfigSaveError::InvalidExistingContent, + ); + assert_eq!(std::fs::read(path).unwrap(), original); } - if overrides.api_key.is_some() { - config.api_key = overrides.api_key; + + #[test] + fn preference_persistence_creates_missing_config_without_api_key() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let patch = UserPreferencePatch::new( + ProviderKind::DeepSeek, + "deepseek-v4-pro", + ThemeName::Catppuccin, + ) + .unwrap(); + + save_user_preferences_in_dir(directory.path(), &patch).unwrap(); + + let content = std::fs::read_to_string(path).unwrap(); + assert!(content.contains("provider = \"deep-seek\"")); + assert!(content.contains("model = \"deepseek-v4-pro\"")); + assert!(content.contains("theme = \"catppuccin\"")); + assert!(!content.contains("api_key")); } - if overrides.base_url.is_some() { - config.base_url = overrides.base_url; + + #[test] + fn preference_persistence_updates_only_root_preferences() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write( + &path, + "provider = \"mock\"\nmodel = \"deepseek-v4-flash\"\ntheme = \"light\"\nunknown = \"keep\"\n\n[tools]\nmax_read_parallel = 7\n", + ) + .unwrap(); + let patch = UserPreferencePatch::new( + ProviderKind::DeepSeek, + "deepseek-v4-pro", + ThemeName::Solarized, + ) + .unwrap(); + + save_user_preferences_in_dir(directory.path(), &patch).unwrap(); + + let document = std::fs::read_to_string(path) + .unwrap() + .parse::() + .unwrap(); + assert_eq!(document["provider"].as_str(), Some("deep-seek")); + assert_eq!(document["model"].as_str(), Some("deepseek-v4-pro")); + assert_eq!(document["theme"].as_str(), Some("solarized")); + assert_eq!(document["unknown"].as_str(), Some("keep")); + assert_eq!(document["tools"]["max_read_parallel"].as_integer(), Some(7)); } - if let Some(reasoning_effort) = overrides.reasoning_effort { - config.reasoning_effort = reasoning_effort; + + #[test] + fn preference_persistence_repeated_save_is_idempotent() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "# retained\ntheme = \"light\"\n").unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + save_user_preferences_in_dir(directory.path(), &patch).unwrap(); + let first = std::fs::read(&path).unwrap(); + save_user_preferences_in_dir(directory.path(), &patch).unwrap(); + + assert_eq!(std::fs::read(path).unwrap(), first); } -} -fn load_auth_key(path: &Path) -> Option { - let content = fs::read_to_string(path).ok()?; - let map: HashMap = serde_json::from_str(&content).ok()?; - map.get("DEEPSEEK_API_KEY").cloned() -} + #[cfg(unix)] + #[test] + fn preference_persistence_rejects_unix_socket_existing_path() { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::net::UnixListener; -pub fn save_api_key(api_key: &str) { - let Some(dir) = config_dir() else { - return; - }; - let _ = fs::create_dir_all(&dir); - let path = dir.join("auth.json"); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let _listener = UnixListener::bind(&path).unwrap(); + let patch = + UserPreferencePatch::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark).unwrap(); + + assert_eq!( + save_user_preferences_in_dir(directory.path(), &patch).unwrap_err(), + UserConfigSaveError::UnsafeExistingPath, + ); + assert!( + std::fs::symlink_metadata(path) + .unwrap() + .file_type() + .is_socket() + ); + } - let mut map: HashMap = fs::read_to_string(&path) - .ok() - .and_then(|c| serde_json::from_str(&c).ok()) - .unwrap_or_default(); + #[test] + fn user_config_save_error_safe_labels_are_stable_and_complete() { + assert_eq!( + USER_CONFIG_SAVE_ERROR_LABELS, + [ + ( + UserConfigSaveError::ConfigDirectoryUnavailable, + "config directory unavailable", + ), + ( + UserConfigSaveError::UnsafeExistingPath, + "unsafe existing config path", + ), + ( + UserConfigSaveError::ExistingFileTooLarge, + "existing config is too large", + ), + ( + UserConfigSaveError::InvalidExistingContent, + "invalid existing config", + ), + ( + UserConfigSaveError::ConcurrentModification, + "config changed during save", + ), + ( + UserConfigSaveError::CreateDirectoryFailed, + "could not create config directory", + ), + ( + UserConfigSaveError::CreateTemporaryFileFailed, + "could not create temporary config", + ), + ( + UserConfigSaveError::ReadFailed, + "could not read existing config", + ), + (UserConfigSaveError::WriteFailed, "could not write config",), + ( + UserConfigSaveError::ReplaceFailed, + "could not replace config", + ), + ( + UserConfigSaveError::RollbackFailed, + "could not restore concurrent config", + ), + ], + ); + for (error, expected) in USER_CONFIG_SAVE_ERROR_LABELS { + assert_eq!(error.safe_label(), expected); + assert!(!expected.contains('/')); + assert!(!expected.contains('\\')); + } + } - map.insert("DEEPSEEK_API_KEY".to_string(), api_key.to_string()); + #[test] + fn public_preference_entrypoint_uses_isolated_orca_home() { + if public_preference_save_child_mode() { + let patch = UserPreferencePatch::new( + ProviderKind::DeepSeek, + "deepseek-v4-flash", + ThemeName::Solarized, + ) + .unwrap(); + save_user_preferences(&patch).unwrap(); + return; + } - if let Ok(content) = serde_json::to_string_pretty(&map) { - let _ = fs::write(&path, content); + let directory = tempfile::tempdir().unwrap(); + let status = public_preference_save_child_command(directory.path()) + .status() + .unwrap(); + assert!(status.success()); + + let content = std::fs::read_to_string(directory.path().join("config.toml")).unwrap(); + assert!(content.contains("provider = \"deep-seek\"")); + assert!(content.contains("model = \"deepseek-v4-flash\"")); + assert!(content.contains("theme = \"solarized\"")); + assert!(!content.contains("api_key")); } -} -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn provider_defaults_to_deepseek_and_parses_explicit_values() { + assert_eq!( + toml::from_str::("").unwrap().provider, + ProviderKind::DeepSeek, + ); + assert_eq!( + toml::from_str::("provider = \"deep-seek\"") + .unwrap() + .provider, + ProviderKind::DeepSeek, + ); + assert_eq!( + toml::from_str::("provider = \"mock\"") + .unwrap() + .provider, + ProviderKind::Mock, + ); + } + + #[test] + fn provider_override_layers_follow_file_env_cli_order() { + let base = FileConfig { + provider: ProviderKind::DeepSeek, + ..FileConfig::default() + }; + let env = ConfigOverrides { + provider: Some(ProviderKind::DeepSeekFixture), + ..ConfigOverrides::default() + }; + let cli = ConfigOverrides { + provider: Some(ProviderKind::Mock), + ..ConfigOverrides::default() + }; + + assert_eq!( + apply_override_layers(base, env, cli).provider, + ProviderKind::Mock, + ); + } + + #[test] + fn trusted_project_config_cannot_override_user_provider() { + let directory = tempfile::tempdir().unwrap(); + let user_dir = directory.path().join("user"); + let project = directory.path().join("project"); + std::fs::create_dir_all(&user_dir).unwrap(); + std::fs::create_dir_all(project.join(".orca")).unwrap(); + std::fs::write(user_dir.join("config.toml"), "provider = \"deep-seek\"\n").unwrap(); + std::fs::write(project.join(".orca/config.toml"), "provider = \"mock\"\n").unwrap(); + crate::config::folder_trust::set_trust_with_config_dir( + &project, + &user_dir, + crate::config::folder_trust::TrustLevel::Trusted, + ) + .unwrap(); + + let config = load_layered_config_from_paths(&user_dir.join("config.toml"), &project); + assert_eq!(config.provider, ProviderKind::DeepSeek); + } fn load_toml(path: &Path) -> FileConfig { let Ok(content) = fs::read_to_string(path) else { @@ -446,6 +2435,20 @@ mod tests { toml::from_str(&content).unwrap_or_default() } + #[test] + fn omitted_and_explicit_auto_theme_parse_as_auto() { + assert_eq!( + toml::from_str::("").unwrap().theme, + ThemeName::Auto + ); + assert_eq!( + toml::from_str::("theme = \"auto\"") + .unwrap() + .theme, + ThemeName::Auto + ); + } + #[test] fn parse_full_config() { let toml = r#" @@ -969,6 +2972,56 @@ auto_memory = true assert!(config.auto_memory); } + #[test] + fn vim_insert_escape_defaults_to_none_and_parses_valid_sequence() { + let omitted: FileConfig = toml::from_str("").unwrap(); + let configured: FileConfig = toml::from_str( + r#" +vim_mode = true +vim_insert_escape = "jj" +"#, + ) + .unwrap(); + + assert_eq!(omitted.vim_insert_escape, None); + assert_eq!( + configured + .vim_insert_escape + .as_ref() + .map(crate::config::VimInsertEscapeSequence::as_str), + Some("jj") + ); + } + + #[test] + fn vim_insert_escape_rejects_invalid_effective_value() { + let error = toml::from_str::(r#"vim_insert_escape = "j""#).unwrap_err(); + assert!(error.to_string().contains("exactly two")); + } + + #[test] + fn invalid_layered_vim_insert_escape_uses_existing_default_fallback() { + let dir = tempfile::tempdir().unwrap(); + let user_path = dir.path().join("config.toml"); + std::fs::write(&user_path, r#"vim_insert_escape = "j""#).unwrap(); + + let config = load_layered_config_from_paths(&user_path, dir.path()); + + assert_eq!(config.vim_insert_escape, None); + assert_eq!(config.theme, ThemeName::Auto); + } + + #[test] + fn terminal_notifications_default_on_and_parse_explicit_values() { + let omitted: FileConfig = toml::from_str("").unwrap(); + let enabled: FileConfig = toml::from_str("terminal_notifications = true").unwrap(); + let disabled: FileConfig = toml::from_str("terminal_notifications = false").unwrap(); + + assert!(omitted.terminal_notifications); + assert!(enabled.terminal_notifications); + assert!(!disabled.terminal_notifications); + } + #[test] fn load_nonexistent_returns_default() { let config = load_toml(Path::new("/nonexistent/path/config.toml")); @@ -1007,6 +3060,278 @@ auto_memory = true assert!(key.is_none()); } + #[test] + fn auth_path_entrypoint_remains_private() { + let source = include_str!("file.rs"); + let public_declaration = + ["pub ", "fn save_api_key_at(path: &Path, api_key: &str)"].concat(); + + assert!(source.contains("fn save_api_key_at(path: &Path, api_key: &str)")); + assert!(!source.contains(&public_declaration)); + } + + #[test] + fn auth_writer_preserves_unrelated_entries_and_never_reports_secret() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("auth.json"); + std::fs::write( + &path, + r#"{"z-provider-token":"keep-z","DEEPSEEK_API_KEY":"old","a-provider-token":"keep-a"}"#, + ) + .unwrap(); + + save_api_key_at(&path, "task-3-new-secret").unwrap(); + + let saved = std::fs::read_to_string(&path).unwrap(); + let map: std::collections::BTreeMap = serde_json::from_str(&saved).unwrap(); + assert_eq!( + map.get("a-provider-token").map(String::as_str), + Some("keep-a") + ); + assert_eq!( + map.get("z-provider-token").map(String::as_str), + Some("keep-z") + ); + assert_eq!( + map.get("DEEPSEEK_API_KEY").map(String::as_str), + Some("task-3-new-secret") + ); + + let error = save_api_key_at_with_ops( + &path, + "task-3-never-report-this-secret", + &SecretBearingWriteFailureOps, + ) + .unwrap_err(); + let reported = format!("{error:?} {}", error.safe_label()); + assert!(!reported.contains("task-3-never-report-this-secret")); + assert!(!reported.contains(path.to_string_lossy().as_ref())); + assert_eq!(std::fs::read_to_string(path).unwrap(), saved); + assert!(auth_temp_files(directory.path()).is_empty()); + } + + #[test] + fn invalid_auth_json_is_left_byte_identical() { + let directory = tempfile::tempdir().unwrap(); + for (name, original) in [ + ("empty.json", b"".as_slice()), + ("invalid-utf8.json", b"{\"key\":\"value\"}\xff".as_slice()), + ("invalid-json.json", b"{not-json".as_slice()), + ("non-string.json", b"{\"other\":42}".as_slice()), + ] { + let path = directory.path().join(name); + std::fs::write(&path, original).unwrap(); + + assert_eq!( + save_api_key_at(&path, "replacement-secret").unwrap_err(), + UserConfigSaveError::InvalidExistingContent, + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + } + assert!(auth_temp_files(directory.path()).is_empty()); + } + + #[cfg(unix)] + #[test] + fn auth_writer_rejects_symlink_without_touching_target() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.json"); + let link = directory.path().join("auth.json"); + let original = br#"{"DEEPSEEK_API_KEY":"target-secret"}"#; + std::fs::write(&target, original).unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + assert_eq!( + save_api_key_at(&link, "replacement-secret").unwrap_err(), + UserConfigSaveError::UnsafeExistingPath, + ); + assert_eq!(std::fs::read(target).unwrap(), original); + assert!( + std::fs::symlink_metadata(link) + .unwrap() + .file_type() + .is_symlink() + ); + assert!(auth_temp_files(directory.path()).is_empty()); + } + + #[test] + fn auth_writer_rejects_oversized_and_directory_paths() { + assert_eq!(MAX_AUTH_FILE_BYTES, 1024 * 1024); + let directory = tempfile::tempdir().unwrap(); + let oversized = directory.path().join("oversized.json"); + let original = vec![b'x'; MAX_AUTH_FILE_BYTES + 1]; + std::fs::write(&oversized, &original).unwrap(); + assert_eq!( + save_api_key_at(&oversized, "replacement-secret").unwrap_err(), + UserConfigSaveError::ExistingFileTooLarge, + ); + assert_eq!(std::fs::read(oversized).unwrap(), original); + + let missing = directory.path().join("missing.json"); + assert_eq!( + save_api_key_at(&missing, &"x".repeat(MAX_AUTH_FILE_BYTES)).unwrap_err(), + UserConfigSaveError::ExistingFileTooLarge, + ); + assert!(!missing.exists()); + + let invalid = directory.path().join("invalid.json"); + let invalid_original = b"{invalid-auth-json"; + std::fs::write(&invalid, invalid_original).unwrap(); + assert_eq!( + save_api_key_at(&invalid, &"x".repeat(MAX_AUTH_FILE_BYTES + 1)).unwrap_err(), + UserConfigSaveError::ExistingFileTooLarge, + ); + assert_eq!(std::fs::read(&invalid).unwrap(), invalid_original); + + let nonregular = directory.path().join("auth-directory"); + std::fs::create_dir(&nonregular).unwrap(); + assert_eq!( + save_api_key_at(&nonregular, "replacement-secret").unwrap_err(), + UserConfigSaveError::UnsafeExistingPath, + ); + assert!(nonregular.is_dir()); + assert!(auth_temp_files(directory.path()).is_empty()); + } + + #[test] + fn auth_writer_rejects_escaped_output_over_limit_without_replacing_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("auth.json"); + let original = br#"{"other":"keep"}"#; + std::fs::write(&path, original).unwrap(); + let escaped_key = "\\".repeat(MAX_AUTH_FILE_BYTES / 2 + 1); + assert!(escaped_key.len() <= MAX_AUTH_FILE_BYTES); + + assert_eq!( + save_api_key_at(&path, &escaped_key).unwrap_err(), + UserConfigSaveError::ExistingFileTooLarge, + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + assert!(auth_temp_files(directory.path()).is_empty()); + } + + #[cfg(unix)] + #[test] + fn auth_writer_rejects_unix_socket() { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::net::UnixListener; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("auth.json"); + let _listener = UnixListener::bind(&path).unwrap(); + + assert_eq!( + save_api_key_at(&path, "replacement-secret").unwrap_err(), + UserConfigSaveError::UnsafeExistingPath, + ); + assert!( + std::fs::symlink_metadata(path) + .unwrap() + .file_type() + .is_socket() + ); + assert!(auth_temp_files(directory.path()).is_empty()); + } + + #[cfg(unix)] + #[test] + fn auth_writer_creates_missing_file_with_secure_metadata_and_is_idempotent() { + use std::os::unix::fs::PermissionsExt; + + let directory = tempfile::tempdir().unwrap(); + #[cfg(target_os = "macos")] + set_inheritable_read_acl(directory.path()); + let path = directory.path().join("auth.json"); + + save_api_key_at(&path, "created-secret").unwrap(); + let first = std::fs::read(&path).unwrap(); + save_api_key_at(&path, "created-secret").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), first); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + #[cfg(target_os = "macos")] + assert!(!has_extended_acl(&path)); + assert!(auth_temp_files(directory.path()).is_empty()); + } + + #[test] + fn auth_writer_rejects_concurrent_update_without_overwrite_or_temp_residual() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("auth.json"); + std::fs::write(&path, r#"{"DEEPSEEK_API_KEY":"old"}"#).unwrap(); + let concurrent = br#"{"DEEPSEEK_API_KEY":"concurrent","other":"keep"}"#; + let operations = ConcurrentMutationOps { + mutation: ConcurrentMutation::ReplaceContents(concurrent), + }; + + assert_eq!( + save_api_key_at_with_ops(&path, "replacement-secret", &operations).unwrap_err(), + UserConfigSaveError::ConcurrentModification, + ); + assert_eq!(std::fs::read(&path).unwrap(), concurrent); + assert!(auth_temp_files(directory.path()).is_empty()); + assert!(acquire_user_config_lock(&path).is_ok()); + } + + #[test] + fn public_auth_entrypoint_uses_isolated_orca_home() { + if public_auth_save_child_mode() { + let result: Result<(), UserConfigSaveError> = save_api_key("isolated-secret"); + result.unwrap(); + return; + } + + let directory = tempfile::tempdir().unwrap(); + let status = public_auth_save_child_command( + directory.path(), + "config::file::tests::public_auth_entrypoint_uses_isolated_orca_home", + ) + .status() + .unwrap(); + assert!(status.success()); + let saved = std::fs::read_to_string(directory.path().join("auth.json")).unwrap(); + let map: std::collections::BTreeMap = serde_json::from_str(&saved).unwrap(); + assert_eq!( + map.get("DEEPSEEK_API_KEY").map(String::as_str), + Some("isolated-secret") + ); + } + + #[test] + fn public_auth_entrypoint_reports_typed_error_without_exposing_context() { + if public_auth_save_child_mode() { + assert_eq!( + save_api_key("isolated-error-secret").unwrap_err(), + UserConfigSaveError::InvalidExistingContent, + ); + return; + } + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("auth.json"); + let original = b"{invalid-auth-json"; + std::fs::write(&path, original).unwrap(); + let output = public_auth_save_child_command( + directory.path(), + "config::file::tests::public_auth_entrypoint_reports_typed_error_without_exposing_context", + ) + .output() + .unwrap(); + + assert!(output.status.success()); + assert_eq!(std::fs::read(&path).unwrap(), original); + let mut reported = output.stdout; + reported.extend_from_slice(&output.stderr); + let reported = String::from_utf8_lossy(&reported); + assert!(!reported.contains("isolated-error-secret")); + assert!(!reported.contains(path.to_string_lossy().as_ref())); + assert!(!reported.to_ascii_lowercase().contains("os error")); + } + #[test] fn layered_config_merges_user_and_project_with_project_security_deny_list() { let dir = tempfile::tempdir().unwrap(); @@ -1126,6 +3451,7 @@ decision = "allow" }; let env = ConfigOverrides { + provider: None, model: Some("deepseek-v4-pro".to_string()), mode: Some(crate::approval_types::ApprovalMode::AutoEdit), api_key: Some("sk-env".to_string()), @@ -1133,6 +3459,7 @@ decision = "allow" reasoning_effort: None, }; let cli = ConfigOverrides { + provider: None, model: Some("auto".to_string()), mode: Some(crate::approval_types::ApprovalMode::Plan), api_key: Some("sk-cli".to_string()), diff --git a/crates/orca-core/src/config/mod.rs b/crates/orca-core/src/config/mod.rs index f00e45b9..496edaf2 100644 --- a/crates/orca-core/src/config/mod.rs +++ b/crates/orca-core/src/config/mod.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use clap::ValueEnum; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::approval_rules::PermissionRules; use crate::approval_types::ApprovalMode; @@ -16,16 +16,85 @@ use crate::tool_types::ToolOutputTruncation; pub mod file; pub mod folder_trust; +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VimInsertEscapeSequence { + value: String, + first: char, + second: char, +} + +impl VimInsertEscapeSequence { + pub fn parse(value: &str) -> Result { + const ERROR: &str = + "vim_insert_escape must contain exactly two non-whitespace, non-control characters"; + + let mut characters = value.chars(); + let Some(first) = characters.next() else { + return Err(ERROR.to_string()); + }; + let Some(second) = characters.next() else { + return Err(ERROR.to_string()); + }; + if characters.next().is_some() + || [first, second] + .into_iter() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err(ERROR.to_string()); + } + Ok(Self { + value: value.to_string(), + first, + second, + }) + } + + pub fn first(&self) -> char { + self.first + } + + pub fn second(&self) -> char { + self.second + } + + pub fn as_str(&self) -> &str { + &self.value + } +} + +impl<'de> Deserialize<'de> for VimInsertEscapeSequence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum ThemeName { #[default] + Auto, Dark, Light, Solarized, Catppuccin, } +impl ThemeName { + pub const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Dark => "dark", + Self::Light => "light", + Self::Solarized => "solarized", + Self::Catppuccin => "catppuccin", + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OutputFormat { Jsonl, @@ -272,8 +341,10 @@ pub struct RunConfig { pub workflows: WorkflowConfig, pub theme: ThemeName, pub vim_mode: bool, + pub vim_insert_escape: Option, pub update_check: bool, pub desktop_notifications: bool, + pub terminal_notifications: bool, pub auto_memory: bool, } @@ -519,6 +590,11 @@ pub fn format_config_show(config: &RunConfig) -> String { .map(|budget| budget.to_string()) .unwrap_or_else(|| "".to_string()); let runtime = runtime_summary(config); + let vim_insert_escape = config + .vim_insert_escape + .as_ref() + .map(|sequence| toml::Value::String(sequence.as_str().to_string()).to_string()) + .unwrap_or_else(|| "\"\"".to_string()); format!( concat!( @@ -534,10 +610,12 @@ pub fn format_config_show(config: &RunConfig) -> String { "cwd = \"{}\"\n", "verifier = \"{}\"\n", "max_budget_usd = \"{}\"\n", - "theme = \"{:?}\"\n", + "theme = \"{}\"\n", "vim_mode = {}\n", + "vim_insert_escape = {}\n", "update_check = {}\n", "desktop_notifications = {}\n", + "terminal_notifications = {}\n", "auto_memory = {}\n", "\n", "[runtime]\n", @@ -591,10 +669,12 @@ pub fn format_config_show(config: &RunConfig) -> String { cwd, verifier, max_budget, - config.theme, + config.theme.as_str(), config.vim_mode, + vim_insert_escape, config.update_check, config.desktop_notifications, + config.terminal_notifications, config.auto_memory, runtime.approval, runtime.filesystem, @@ -696,6 +776,38 @@ mod tests { use crate::approval_types::ApprovalMode; use crate::model::ModelSelection; + #[test] + fn theme_name_defaults_to_auto_and_round_trips_all_values() { + assert_eq!(ThemeName::default(), ThemeName::Auto); + + for (wire, theme) in [ + ("\"auto\"", ThemeName::Auto), + ("\"dark\"", ThemeName::Dark), + ("\"light\"", ThemeName::Light), + ("\"solarized\"", ThemeName::Solarized), + ("\"catppuccin\"", ThemeName::Catppuccin), + ] { + assert_eq!(serde_json::from_str::(wire).unwrap(), theme); + assert_eq!(serde_json::to_string(&theme).unwrap(), wire); + } + } + + #[test] + fn vim_insert_escape_validates_exactly_two_printable_non_whitespace_scalars() { + for (value, first, second) in [("jj", 'j', 'j'), ("jk", 'j', 'k'), ("你好", '你', '好')] + { + let sequence = VimInsertEscapeSequence::parse(value).unwrap(); + assert_eq!(sequence.first(), first); + assert_eq!(sequence.second(), second); + assert_eq!(sequence.as_str(), value); + } + + for value in ["", "j", "jjj", "j ", " j", "\nj", "j\u{7f}"] { + let error = VimInsertEscapeSequence::parse(value).unwrap_err(); + assert!(error.contains("exactly two"), "{value:?}: {error}"); + } + } + #[test] fn format_config_show_redacts_api_key_and_includes_effective_values() { let config = RunConfig { @@ -729,10 +841,12 @@ mod tests { subagents: SubagentConfig::default(), tools: ToolConfig::default(), workflows: WorkflowConfig::default(), - theme: ThemeName::Dark, + theme: ThemeName::Auto, vim_mode: true, + vim_insert_escape: Some(VimInsertEscapeSequence::parse("j\\").unwrap()), update_check: false, desktop_notifications: true, + terminal_notifications: false, auto_memory: true, }; @@ -749,6 +863,20 @@ mod tests { assert!(shown.contains("network = \"allowed\"")); assert!(shown.contains("approval = \"full-auto\"")); assert!(shown.contains("history = \"disabled\"")); + assert!(shown.contains("theme = \"auto\"")); + let vim_insert_escape_line = shown + .lines() + .find(|line| line.starts_with("vim_insert_escape = ")) + .expect("vim insert escape line"); + let parsed: toml::Value = vim_insert_escape_line.parse().unwrap(); + assert_eq!( + parsed + .get("vim_insert_escape") + .and_then(toml::Value::as_str), + Some("j\\") + ); + assert!(shown.contains("desktop_notifications = true")); + assert!(shown.contains("terminal_notifications = false")); assert!(shown.contains("api_key = \"\"")); assert!(!shown.contains("sk-secret")); } diff --git a/crates/orca-runtime/examples/goal_mode_realapi.rs b/crates/orca-runtime/examples/goal_mode_realapi.rs index e458f626..1bdcbd6f 100644 --- a/crates/orca-runtime/examples/goal_mode_realapi.rs +++ b/crates/orca-runtime/examples/goal_mode_realapi.rs @@ -234,8 +234,10 @@ fn real_api_config(api_key: String, max_budget_usd: f64) -> Result) -> RunConfig { workflows: WorkflowConfig::default(), theme: ThemeName::default(), vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/controller.rs b/crates/orca-runtime/src/controller.rs index 42cfa66c..6a95cc49 100644 --- a/crates/orca-runtime/src/controller.rs +++ b/crates/orca-runtime/src/controller.rs @@ -1412,8 +1412,10 @@ mod tests { workflows: Default::default(), theme: orca_core::config::ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/lifecycle.rs b/crates/orca-runtime/src/lifecycle.rs index 597af1ca..e4330ecb 100644 --- a/crates/orca-runtime/src/lifecycle.rs +++ b/crates/orca-runtime/src/lifecycle.rs @@ -1501,8 +1501,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/memory.rs b/crates/orca-runtime/src/memory.rs index b8cc83e5..9c23a55f 100644 --- a/crates/orca-runtime/src/memory.rs +++ b/crates/orca-runtime/src/memory.rs @@ -312,8 +312,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/provider_turn.rs b/crates/orca-runtime/src/provider_turn.rs index f675ab2f..84514c35 100644 --- a/crates/orca-runtime/src/provider_turn.rs +++ b/crates/orca-runtime/src/provider_turn.rs @@ -830,8 +830,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/runtime_readonly_tool_turn.rs b/crates/orca-runtime/src/runtime_readonly_tool_turn.rs index de714a3f..964add11 100644 --- a/crates/orca-runtime/src/runtime_readonly_tool_turn.rs +++ b/crates/orca-runtime/src/runtime_readonly_tool_turn.rs @@ -711,8 +711,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::default(), vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/runtime_tool_scheduler.rs b/crates/orca-runtime/src/runtime_tool_scheduler.rs index 02bf6034..cc269a6c 100644 --- a/crates/orca-runtime/src/runtime_tool_scheduler.rs +++ b/crates/orca-runtime/src/runtime_tool_scheduler.rs @@ -102,8 +102,10 @@ mod tests { external_tools: Vec::new(), max_budget_usd: None, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/runtime_turn_loop.rs b/crates/orca-runtime/src/runtime_turn_loop.rs index 2ecfb8eb..fafef0c8 100644 --- a/crates/orca-runtime/src/runtime_turn_loop.rs +++ b/crates/orca-runtime/src/runtime_turn_loop.rs @@ -338,8 +338,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/server.rs b/crates/orca-runtime/src/server.rs index 63698290..c7108ca0 100644 --- a/crates/orca-runtime/src/server.rs +++ b/crates/orca-runtime/src/server.rs @@ -6701,8 +6701,10 @@ rl.on("line", (line) => { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/session.rs b/crates/orca-runtime/src/session.rs index e32f688b..d92d78a6 100644 --- a/crates/orca-runtime/src/session.rs +++ b/crates/orca-runtime/src/session.rs @@ -657,8 +657,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/subagent_execution.rs b/crates/orca-runtime/src/subagent_execution.rs index dfbaaa46..001e6d5d 100644 --- a/crates/orca-runtime/src/subagent_execution.rs +++ b/crates/orca-runtime/src/subagent_execution.rs @@ -667,8 +667,10 @@ mod tests { workflows: Default::default(), theme: orca_core::config::ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/thread.rs b/crates/orca-runtime/src/thread.rs index 9334cdc8..ad5a4c76 100644 --- a/crates/orca-runtime/src/thread.rs +++ b/crates/orca-runtime/src/thread.rs @@ -624,8 +624,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::default(), vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/tool_execution.rs b/crates/orca-runtime/src/tool_execution.rs index 6593fe14..695b4d0d 100644 --- a/crates/orca-runtime/src/tool_execution.rs +++ b/crates/orca-runtime/src/tool_execution.rs @@ -1087,8 +1087,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/tool_invocation.rs b/crates/orca-runtime/src/tool_invocation.rs index f40f0828..491c1968 100644 --- a/crates/orca-runtime/src/tool_invocation.rs +++ b/crates/orca-runtime/src/tool_invocation.rs @@ -397,8 +397,10 @@ mod tests { external_tools, max_budget_usd: None, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/tool_turn.rs b/crates/orca-runtime/src/tool_turn.rs index aa713242..83e8e7ab 100644 --- a/crates/orca-runtime/src/tool_turn.rs +++ b/crates/orca-runtime/src/tool_turn.rs @@ -807,8 +807,10 @@ mod tests { external_tools, max_budget_usd: None, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/workflow/runner.rs b/crates/orca-runtime/src/workflow/runner.rs index 34c58ff4..aef8d56a 100644 --- a/crates/orca-runtime/src/workflow/runner.rs +++ b/crates/orca-runtime/src/workflow/runner.rs @@ -2140,8 +2140,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: Default::default(), vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/src/workflow_execution.rs b/crates/orca-runtime/src/workflow_execution.rs index b062b8e2..fb2a7f17 100644 --- a/crates/orca-runtime/src/workflow_execution.rs +++ b/crates/orca-runtime/src/workflow_execution.rs @@ -574,8 +574,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: orca_core::config::ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/tests/acp_agent.rs b/crates/orca-runtime/tests/acp_agent.rs index 31af12e2..5f392868 100644 --- a/crates/orca-runtime/tests/acp_agent.rs +++ b/crates/orca-runtime/tests/acp_agent.rs @@ -103,8 +103,10 @@ fn test_config(cwd: PathBuf) -> RunConfig { workflows: WorkflowConfig::default(), theme: ThemeName::default(), vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-runtime/tests/runtime_host.rs b/crates/orca-runtime/tests/runtime_host.rs index 36c34163..93b4dd93 100644 --- a/crates/orca-runtime/tests/runtime_host.rs +++ b/crates/orca-runtime/tests/runtime_host.rs @@ -722,8 +722,10 @@ fn test_config(cwd: PathBuf) -> RunConfig { workflows: WorkflowConfig::default(), theme: ThemeName::default(), vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-tui/Cargo.toml b/crates/orca-tui/Cargo.toml index d43d79dc..c0703947 100644 --- a/crates/orca-tui/Cargo.toml +++ b/crates/orca-tui/Cargo.toml @@ -20,10 +20,18 @@ serde_json = { workspace = true } ratatui = { workspace = true, features = ["scrolling-regions", "unstable-rendered-line-info"] } crossterm = { workspace = true } tui-textarea = { workspace = true } +qwertty = { workspace = true } +supports-color = { workspace = true } +tokio = { workspace = true } pulldown-cmark = { workspace = true } +unicode-segmentation = { workspace = true } unicode-width = { workspace = true } dirs = { workspace = true } crossbeam-channel = { workspace = true } +syntect = { workspace = true } +two-face = { workspace = true } +similar = { workspace = true, features = ["inline"] } +libc = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/orca-tui/src/action_dispatcher.rs b/crates/orca-tui/src/action_dispatcher.rs index 38e2fc1a..f052d273 100644 --- a/crates/orca-tui/src/action_dispatcher.rs +++ b/crates/orca-tui/src/action_dispatcher.rs @@ -199,7 +199,18 @@ fn reject_overflowed_action(event_tx: &Sender, action: UserAction) { let message = "TUI command queue is full; command rejected".to_string(); match action { UserAction::Submit(prompt) | UserAction::SubmitWithMentions { prompt, .. } => { - let _ = event_tx.try_send(TuiEvent::SubmissionRejected { prompt, message }); + let _ = event_tx.try_send(TuiEvent::SubmissionRejected { + queued_id: None, + prompt, + message, + }); + } + UserAction::SubmitQueued { id, prompt, .. } => { + let _ = event_tx.try_send(TuiEvent::SubmissionRejected { + queued_id: Some(id), + prompt, + message, + }); } UserAction::SubmitWorkflowNotification(_) | UserAction::RunWorkflow { .. } @@ -379,7 +390,9 @@ mod tests { assert!(matches!( event_rx.recv_timeout(Duration::from_secs(1)), - Ok(TuiEvent::SubmissionRejected { prompt, message }) + Ok(TuiEvent::SubmissionRejected { + prompt, message, .. + }) if prompt == "third" && message.contains("queue is full") )); dispatcher.shutdown().expect("shutdown dispatcher"); diff --git a/crates/orca-tui/src/app.rs b/crates/orca-tui/src/app.rs index d9ca5140..4cf6c58f 100644 --- a/crates/orca-tui/src/app.rs +++ b/crates/orca-tui/src/app.rs @@ -1,18 +1,14 @@ use crossbeam_channel as mpsc; -use std::collections::VecDeque; use std::io; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crossterm::ExecutableCommand; -use crossterm::event::{ - self, EnableBracketedPaste, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, - KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, -}; -use crossterm::terminal::{self, EnterAlternateScreen}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; +use tui_textarea::{Input, TextArea}; #[cfg(test)] use orca_core::cancel::CancelToken; @@ -31,32 +27,193 @@ use crate::background_tasks::{ foreground_task_for_tui, notify_recovered_background_approvals_for_tui, stop_task_for_tui, }; use crate::bridge; +use crate::capability_backend::CapabilityBackend; use crate::channels::{tui_event_channel, user_action_channel}; use crate::clipboard; +use crate::composer_input_actions::refresh_input_menus; use crate::composer_textarea::{ make_setup_textarea, make_textarea, textarea_cursor_byte_index, textarea_text, }; +use crate::diagnostics::{ + DiagnosticSnapshot, KeybindingsDiagnostic, KeybindingsLocation, SnapshotInput, +}; use crate::frame_scheduler::{FrameScheduler, IterationEvent, run_event_loop_iteration}; use crate::hosted_runtime::{TuiHostedEventObserver, TuiHostedOperationOutcome}; use crate::input_event_actions::{ - BatchedInputEvent, MouseFlow, coalesce_input_events, handle_mouse_event, handle_paste_event, - handle_resize_event, handle_scroll_lines, should_queue_input_event, + BatchedInputEvent, MouseFlow, coalesce_input_events, consume_focus_event, handle_mouse_event, + handle_paste_event, handle_resize_event, handle_scroll_lines, should_queue_input_event, }; +use crate::input_runtime::{InputControl, InputRuntime, InputRuntimeOptions}; use crate::interaction_broker::TuiInteractionBroker; +use crate::key_event_actions::{DynamicKeyEventFlow, handle_key_event_preflight_dynamic}; +#[cfg(test)] use crate::key_event_actions::{KeyEventFlow, handle_key_event_preflight}; +use crate::keybindings::{ + InputOwnerFingerprint, KeymapReloader, KeymapRuntime, ModalOwner, ReloadOutcome, + ShortcutInvocation, keybindings_location, +}; use crate::mention_search_manager::MentionSearchManager; use crate::operation_controller::{TuiOperationController, TuiTurnControl}; use crate::runtime_event_actions::handle_runtime_event; use crate::runtime_interaction_adapter::{ TuiApprovalHandler, TuiMcpElicitationHandler, TuiPermissionRequestHandler, TuiUserInputHandler, }; -use crate::status_key_actions::{StatusKeyFlow, handle_status_key}; +#[cfg(test)] +use crate::status_key_actions::handle_status_key; +use crate::status_key_actions::{StatusKeyFlow, handle_status_key_dynamic}; use crate::submitted_turn::SubmittedTurn; -use crate::terminal_lifecycle::TerminalCleanup; +use crate::terminal_capabilities::{TerminalProfile, resolve_base_theme}; +use crate::terminal_presentation::{TerminalPresentation, TerminalPresentationProfile}; use crate::theme::Theme; use crate::types::{AppState, AppStatus, ChatMessage, TuiEvent, UserAction}; use crate::ui; -use crate::vim::VimState; +use crate::vim::{PendingInsertEscapeFlow, VimState}; +use crate::workspace_status; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PendingInsertEscapeRouting { + Continue, + Consumed, +} + +fn apply_onboarding_theme_preview( + requested: orca_core::config::ThemeName, + terminal_profile: TerminalProfile, + theme: &mut Theme, + state: &mut AppState, +) { + let resolved = resolve_base_theme(requested, terminal_profile.background); + *theme = Theme::resolve(requested, terminal_profile); + state.apply_theme_projection(theme); + state.diagnostics.set_theme_projection(requested, resolved); +} + +fn apply_status_key_flow( + flow: StatusKeyFlow, + terminal_profile: TerminalProfile, + theme: &mut Theme, + state: &mut AppState, +) -> Option { + match flow { + StatusKeyFlow::Continue => None, + StatusKeyFlow::PreviewTheme(requested) => { + apply_onboarding_theme_preview(requested, terminal_profile, theme, state); + None + } + StatusKeyFlow::Exit(code) => Some(code), + } +} + +pub(crate) fn input_owner_fingerprint( + state: &AppState, + vim_state: &VimState, +) -> InputOwnerFingerprint { + let context = match state.status { + AppStatus::Idle | AppStatus::WaitingUserInput => crate::shortcuts::ShortcutContext::Idle, + AppStatus::Running | AppStatus::Compacting => crate::shortcuts::ShortcutContext::Running, + AppStatus::WaitingApproval => crate::shortcuts::ShortcutContext::Approval, + AppStatus::Setup | AppStatus::SessionPicker => crate::shortcuts::ShortcutContext::Global, + }; + let modal = if state.status == AppStatus::Setup { + ModalOwner::Setup + } else if state.status == AppStatus::SessionPicker { + ModalOwner::SessionPicker + } else if state.status == AppStatus::WaitingApproval { + ModalOwner::Approval + } else if state.transcript_search.open { + ModalOwner::TranscriptSearch + } else if state.show_shortcuts { + ModalOwner::Shortcuts + } else if state.slash_menu.is_some() { + ModalOwner::SlashMenu + } else if state.mention.phase.is_some() || !state.mention.candidates.is_empty() { + ModalOwner::MentionMenu + } else if state.panel_mode != crate::types::PanelMode::Conversation { + ModalOwner::WorkflowPanel + } else { + ModalOwner::None + }; + InputOwnerFingerprint { + context, + modal, + panel: state.panel_mode, + vim_mode: vim_state.enabled.then_some(vim_state.mode), + } +} + +fn keybinding_poll_timeout( + frame_timeout: Duration, + now: Instant, + chord_deadline: Option, +) -> Duration { + chord_deadline + .map(|deadline| deadline.saturating_duration_since(now)) + .map_or(frame_timeout, |chord_wait| frame_timeout.min(chord_wait)) +} + +fn refresh_after_insert_escape_flush( + state: &mut AppState, + config: &RunConfig, + textarea: &TextArea<'_>, +) { + state.reset_history_navigation(); + refresh_input_menus(textarea, state, config); +} + +fn resolve_pending_insert_escape_before_routing( + event: &Event, + now: Instant, + vim_state: &mut VimState, + textarea: &mut TextArea<'_>, + state: &mut AppState, + config: &RunConfig, + theme: &Theme, +) -> PendingInsertEscapeRouting { + let Event::Key(key) = event else { + return PendingInsertEscapeRouting::Continue; + }; + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return PendingInsertEscapeRouting::Continue; + } + match vim_state.resolve_pending_insert_escape(&Input::from(event.clone()), now, textarea) { + PendingInsertEscapeFlow::Consumed => { + vim_state.configure_block(textarea, theme); + PendingInsertEscapeRouting::Consumed + } + PendingInsertEscapeFlow::Flushed => { + refresh_after_insert_escape_flush(state, config, textarea); + PendingInsertEscapeRouting::Continue + } + PendingInsertEscapeFlow::NoPending => PendingInsertEscapeRouting::Continue, + } +} + +fn flush_pending_insert_escape_before_non_key( + vim_state: &mut VimState, + textarea: &mut TextArea<'_>, + state: &mut AppState, + config: &RunConfig, +) -> bool { + if !vim_state.flush_pending_insert_escape(textarea) { + return false; + } + refresh_after_insert_escape_flush(state, config, textarea); + true +} + +fn flush_expired_insert_escape( + now: Instant, + vim_state: &mut VimState, + textarea: &mut TextArea<'_>, + state: &mut AppState, + config: &RunConfig, +) -> bool { + if !vim_state.flush_expired_insert_escape(now, textarea) { + return false; + } + refresh_after_insert_escape_flush(state, config, textarea); + true +} pub fn run_tui(config: RunConfig) -> i32 { match run_tui_inner(config) { @@ -69,43 +226,36 @@ pub fn run_tui(config: RunConfig) -> i32 { } fn run_tui_inner(mut config: RunConfig) -> io::Result { + let pending_input_runtime = InputRuntime::start(InputRuntimeOptions { + theme: config.theme, + focus_events: config.terminal_notifications, + })?; + let terminal_identity = qwertty::caps::identity_from_env(None, qwertty::caps::std_env_source); + let terminal_profile = pending_input_runtime.profile(); + let presentation_profile = TerminalPresentationProfile::from_identity(&terminal_identity); + let mut theme = Theme::resolve(config.theme, terminal_profile); + let input_rx = pending_input_runtime.events().clone(); + let focus_rx = pending_input_runtime.focus_events().clone(); + let input_control_rx = pending_input_runtime.controls().clone(); + let presentation = + TerminalPresentation::new(config.terminal_notifications, presentation_profile); + const FRAME_INTERVAL: Duration = Duration::from_millis(16); const ANIMATION_INTERVAL: Duration = Duration::from_millis(80); const MAX_INPUT_EVENTS_PER_BATCH: usize = 64; const MAX_RUNTIME_EVENTS_PER_BATCH: usize = 256; const MAX_SUPERVISED_TUI_TASKS: usize = 32; - terminal::enable_raw_mode()?; - let mut pending_terminal_cleanup = TerminalCleanup::raw_mode_enabled(); - let mut stdout = io::stdout(); - // Alternate screen: the fullscreen UI owns the whole viewport, and the - // alt buffer has NO scrollback — so the terminal's native scrollbar - // cannot drag the viewport away from the frame we repaint (which used to - // shear the UI). Selection, copying, and wheel scrolling are all - // implemented in-app, so nothing native is lost; on exit the primary - // screen returns with the shell's history intact. - pending_terminal_cleanup.set_alternate_screen(stdout.execute(EnterAlternateScreen).is_ok()); - pending_terminal_cleanup.set_mouse_captured(stdout.execute(EnableMouseCapture).is_ok()); - pending_terminal_cleanup.set_bracketed_paste(stdout.execute(EnableBracketedPaste).is_ok()); - // Kitty keyboard protocol: push enhancement AFTER entering alternate screen, - // otherwise the terminal may reset the keyboard state stack on screen switch. - pending_terminal_cleanup.set_keyboard_enhanced( - stdout - .execute(PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES - | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS, - )) - .is_ok(), - ); - - let backend = CrosstermBackend::new(stdout); + let backend = CapabilityBackend::new(CrosstermBackend::new(io::stdout()), theme.color_level); + let workspace_root = syntax_workspace_root(&config); let (event_tx, pending_event_rx) = tui_event_channel(); let (action_tx, action_rx) = user_action_channel(); let (mention_registry_tx, mention_registry_rx) = mpsc::bounded(1); - let mut mention_search = - MentionSearchManager::new_roots(mention_search_roots(&config), event_tx.clone()); + let mut mention_search = MentionSearchManager::new_roots( + mention_search_roots(&config, &workspace_root), + event_tx.clone(), + ); let pending_workflow_notifications: bridge::PendingWorkflowNotifications = bridge::PendingWorkflowNotifications::new(); @@ -125,24 +275,32 @@ fn run_tui_inner(mut config: RunConfig) -> io::Result { Vec::new() }; - let cwd_display = config - .cwd - .as_deref() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) - .display() - .to_string(); - let cwd_display = shorten_home(&cwd_display); - + let workspace_status = workspace_status::snapshot(&workspace_root); let mut state = AppState::new( action_tx.clone(), config.app_version.clone(), model_name, - cwd_display, + workspace_status.cwd, + ); + let mut keymap_runtime = KeymapRuntime::new(Arc::clone(&state.keymap)); + let keybindings_location = keybindings_location(); + state.keybindings_diagnostic = KeybindingsDiagnostic::built_ins(keymap_runtime.generation()); + state.diagnostics = diagnostic_snapshot_for_startup( + &config, + &terminal_identity, + terminal_profile, + presentation_profile, + keybindings_location, ); + let mut keymap_reloader = crate::keybindings::keybindings_path() + .map(|path| KeymapReloader::start(path, Instant::now())); + if let Some(reloader) = &mut keymap_reloader { + reloader.request_reload(Instant::now()); + } + state.workspace_git = workspace_status.git; state.approval_mode = config.approval_mode; state.reasoning_effort = config.reasoning_effort; - let theme = Theme::named(config.theme); + state.auth_configured = config.api_key.is_some(); if should_show_picker && !picker_sessions.is_empty() { state.status = AppStatus::SessionPicker; state.session_picker_sessions = picker_sessions; @@ -150,7 +308,7 @@ fn run_tui_inner(mut config: RunConfig) -> io::Result { if needs_setup { state.status = AppStatus::Setup; - state.setup_step = 0; + state.initialize_onboarding(&config); } let initial_prompt = if config.prompt.trim().is_empty() { @@ -163,36 +321,41 @@ fn run_tui_inner(mut config: RunConfig) -> io::Result { config.history_mode, HistoryMode::Resume(_) | HistoryMode::Fork(_) ) { - if let Ok(transcript) = orca_runtime::history::load_session(match &config.history_mode { + orca_runtime::history::load_session(match &config.history_mode { HistoryMode::Resume(selector) | HistoryMode::Fork(selector) => selector, HistoryMode::Record | HistoryMode::Disabled => "", - }) { - for message in &transcript.messages { - if let Some(chat_message) = chat_message_from_history(message.clone()) { - state.push_message(chat_message); - } - } - if let Some((explanation, plan)) = &transcript.plan { - state.current_plan = Some((explanation.clone(), plan.clone())); - } - if !state.messages.is_empty() { - let label = if matches!(config.history_mode, HistoryMode::Fork(_)) { - "Forked saved conversation." - } else { - "Resumed saved conversation." - }; - state.push_message(ChatMessage::System(label.to_string())); - } - // The preloaded transcript is entirely past turns; freeze it so the next - // turn (or an initial prompt) starts a fresh live suffix. - state.finalized_count = state.messages.len(); - Some(transcript) - } else { - None - } + }) + .ok() } else { None }; + let replay_messages = startup_preloaded_transcript + .iter() + .flat_map(|transcript| transcript.messages.iter().cloned()) + .filter_map(chat_message_from_history); + configure_and_preload_tui_state( + &mut state, + workspace_root.clone(), + theme.syntax_theme, + theme.color_level, + replay_messages, + ); + if let Some(transcript) = &startup_preloaded_transcript { + if let Some((explanation, plan)) = &transcript.plan { + state.current_plan = Some((explanation.clone(), plan.clone())); + } + if !state.messages.is_empty() { + let label = if matches!(config.history_mode, HistoryMode::Fork(_)) { + "Forked saved conversation." + } else { + "Resumed saved conversation." + }; + state.push_message(ChatMessage::System(label.to_string())); + } + // The preloaded transcript is entirely past turns; freeze it so the next + // turn (or an initial prompt) starts a fresh live suffix. + state.finalized_count = state.messages.len(); + } let shared_config = Arc::new(Mutex::new(config.clone())); let agent_config = Arc::clone(&shared_config); @@ -205,7 +368,7 @@ fn run_tui_inner(mut config: RunConfig) -> io::Result { let _ = mention_registry_tx.send(agent_mcp_registry.clone()); let agent_controller = TuiOperationController::hosted(TuiInteractionBroker::default()); - let mut agent_runtime = TuiAgentRuntime::spawn_hosted( + let mut agent_runtime = match TuiAgentRuntime::spawn_hosted( action_rx, event_tx.clone(), MAX_SUPERVISED_TUI_TASKS, @@ -222,13 +385,23 @@ fn run_tui_inner(mut config: RunConfig) -> io::Result { host, ); }, - )?; - // These moved bindings are declared after the runtime so unwinding drops - // the event receiver and restores the terminal before the runtime joins. - let terminal_cleanup = pending_terminal_cleanup; + ) { + Ok(runtime) => runtime, + Err(error) => { + let mut terminal_input = pending_input_runtime; + terminal_input.finish()?; + return Err(error); + } + }; + // Declare terminal ownership after the agent runtime. The cleanup wrapper + // below resets presentation output, drops ratatui, and then joins qwertty + // on every non-panic return from the frame loop. + let terminal_input = pending_input_runtime; let event_rx = pending_event_rx; - let mut vim_state = VimState::new(config.vim_mode); + let mut vim_state = + VimState::with_insert_escape(config.vim_mode, config.vim_insert_escape.clone()); + state.sync_vim_mode(&vim_state); let mut textarea = if needs_setup { make_setup_textarea(&theme) } else { @@ -249,83 +422,311 @@ fn run_tui_inner(mut config: RunConfig) -> io::Result { // blank canvas rather than whatever the alt screen came up with. terminal.clear()?; - let exit_code; - - terminal.draw(|f| ui::render(f, &mut state, &textarea, &theme))?; - let started_at = Instant::now(); - let mut scheduler = FrameScheduler::new(started_at, FRAME_INTERVAL, ANIMATION_INTERVAL); - scheduler.did_draw(started_at); - - 'main: loop { - let now = Instant::now(); - if let Ok(registry) = mention_registry_rx.try_recv() { - mention_search.install_registry(registry); - } - // The copy notice and edge-drag auto-scroll count as animation so the - // idle loop keeps drawing frames: the notice until it expires (expiry - // clears it while THIS iteration still counts as animating, so - // `did_animate` marks the frame dirty and the final redraw removes it - // from the screen), and the edge drag so scrolling continues while the - // pointer sits still on the transcript's first/last row. - let animation_active = state.status == AppStatus::Running - || state.copy_notice.is_some() - || state.drag_edge_scroll.is_some(); - if state.copy_notice.is_some() && state.copy_notice_at(now).is_none() { - state.copy_notice = None; - } - if animation_active && scheduler.animation_due(now) { - state.advance_tick(); - state.apply_drag_edge_scroll(); - scheduler.did_animate(now); - } - - let mut input_events = Vec::new(); - if event::poll(scheduler.poll_timeout(now, animation_active))? { - let first = event::read()?; - if should_queue_input_event(&first) { - input_events.push(first); - } - while input_events.len() < MAX_INPUT_EVENTS_PER_BATCH && event::poll(Duration::ZERO)? { - let next = event::read()?; - if should_queue_input_event(&next) { - input_events.push(next); + let resources = (terminal, presentation, terminal_input); + let exit_code = with_terminal_presentation_cleanup( + resources, + |(terminal, presentation, _terminal_input)| { + let initial_status = state.status; + initialize_terminal_presentation( + terminal, + |terminal| { + let _ = presentation + .write_pending(terminal.backend_mut().inner_mut(), initial_status); + Ok(()) + }, + |terminal| { + let (_, started_at, completed_at) = + measure_successful_draw(Instant::now, || { + terminal + .draw(|f| ui::render(f, &mut state, &textarea, &theme)) + .map(|_| ()) + })?; + state + .frame_metrics + .record_successful_draw(started_at, completed_at); + Ok(()) + }, + )?; + let started_at = Instant::now(); + let mut scheduler = FrameScheduler::new(started_at, FRAME_INTERVAL, ANIMATION_INTERVAL); + scheduler.did_draw(started_at); + + let exit_code = 'main: loop { + let now = Instant::now(); + keymap_runtime.expire_pending(now); + if let Some(reloader) = &mut keymap_reloader { + reloader.request_reload(now); + if let Some(observation) = reloader.try_recv() { + match keymap_runtime.apply_observation(observation) { + ReloadOutcome::Unchanged => { + if !keymap_runtime.last_observation_rejected() { + state + .keybindings_diagnostic + .accepted_unchanged(keymap_runtime.generation()); + } + } + ReloadOutcome::Applied => { + state + .keybindings_diagnostic + .applied_custom(keymap_runtime.generation()); + state.keymap = keymap_runtime.keymap(); + scheduler.mark_dirty(); + } + ReloadOutcome::RestoredDefaults => { + state + .keybindings_diagnostic + .restored_built_ins(keymap_runtime.generation()); + state.keymap = keymap_runtime.keymap(); + scheduler.mark_dirty(); + } + ReloadOutcome::Rejected(message) => { + state + .keybindings_diagnostic + .rejected(keymap_runtime.generation()); + state.push_message(ChatMessage::System(message)); + scheduler.mark_dirty(); + } + } + } + } + if flush_expired_insert_escape( + now, + &mut vim_state, + &mut textarea, + &mut state, + &config, + ) { + scheduler.mark_dirty(); + } + if let Ok(registry) = mention_registry_rx.try_recv() { + mention_search.install_registry(registry); + } + poll_edit_highlight(&mut state, &mut scheduler); + // The copy notice and edge-drag auto-scroll count as animation so the + // idle loop keeps drawing frames: the notice until it expires (expiry + // clears it while THIS iteration still counts as animating, so + // `did_animate` marks the frame dirty and the final redraw removes it + // from the screen), and the edge drag so scrolling continues while the + // pointer sits still on the transcript's first/last row. + let animation_active = state.status == AppStatus::Running + || state.fps_hud_enabled + || state.copy_notice.is_some() + || state.drag_edge_scroll.is_some() + || edit_highlight_animation_active(&state) + || presentation.animation_active(state.status); + if state.copy_notice.is_some() && state.copy_notice_at(now).is_none() { + state.copy_notice = None; + } + if animation_active && scheduler.animation_due(now) { + state.advance_tick(); + presentation.advance_tick(); + state.apply_drag_edge_scroll(); + scheduler.did_animate(now); } - } - } - let iteration = run_event_loop_iteration( - &mut scheduler, - coalesce_input_events(input_events, 3), - event_rx.try_iter(), - MAX_INPUT_EVENTS_PER_BATCH, - MAX_RUNTIME_EVENTS_PER_BATCH, - Instant::now, - |event| -> io::Result> { - match event { - IterationEvent::Input(input_event) => match input_event { - BatchedInputEvent::ScrollLines(lines) => { - handle_scroll_lines(&mut state, lines, Instant::now()); - } - BatchedInputEvent::Event(ev) => { - if handle_paste_event(&ev, &mut state, &config, &mut textarea) { - return Ok(None); - } - if handle_resize_event(&ev, &mut state) { - return Ok(None); + let input_events = match receive_prioritized_input_or_control( + &input_rx, + &focus_rx, + &input_control_rx, + keybinding_poll_timeout( + scheduler.poll_timeout(now, animation_active), + now, + keymap_runtime.next_deadline(), + ), + MAX_INPUT_EVENTS_PER_BATCH, + ) { + Ok(InputWake::Events(events)) => events + .into_iter() + .filter(should_queue_input_event) + .collect(), + Ok(InputWake::Suspend { acknowledge }) => { + keymap_runtime.clear_for_suspend(); + state.frame_metrics.reset_rolling(); + acknowledge.send(()).map_err(|_| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "terminal input runtime dropped suspend acknowledgement", + ) + })?; + loop { + match input_control_rx.recv() { + Ok(InputControl::Resumed) => { + resume_terminal_render(terminal, &mut scheduler, presentation)?; + break; + } + Ok(InputControl::Suspend { acknowledge }) => { + let _ = acknowledge.send(()); + } + Err(_) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "terminal input runtime disconnected while suspended", + )); + } } - match handle_mouse_event(&ev, &mut state, &mut textarea, Instant::now()) - { - MouseFlow::NotMouse => {} - MouseFlow::Handled => return Ok(None), - MouseFlow::SyntheticEnter => { - // A click confirmed the focused row; run - // the exact same path a real Enter takes. - let enter_key = - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); - let enter_event = Event::Key(enter_key); - if let StatusKeyFlow::Exit(code) = handle_status_key( - &enter_event, - &enter_key, + } + Vec::new() + } + Ok(InputWake::Resumed) | Err(mpsc::RecvTimeoutError::Timeout) => Vec::new(), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "terminal input runtime disconnected", + )); + } + }; + + let iteration = run_event_loop_iteration( + &mut scheduler, + coalesce_input_events(input_events, 3), + event_rx.try_iter(), + usize::MAX, + MAX_RUNTIME_EVENTS_PER_BATCH, + Instant::now, + |event| -> io::Result> { + match event { + IterationEvent::Input(input_event) => match input_event { + BatchedInputEvent::ScrollLines(lines) => { + keymap_runtime.clear_for_non_key(); + flush_pending_insert_escape_before_non_key( + &mut vim_state, + &mut textarea, + &mut state, + &config, + ); + vim_state.cancel_pending_command(); + handle_scroll_lines(&mut state, lines, Instant::now()); + } + BatchedInputEvent::Event(ev) => { + state.sync_vim_mode(&vim_state); + if consume_focus_event(&ev, presentation) { + keymap_runtime.clear_for_non_key(); + return Ok(None); + } + if resolve_pending_insert_escape_before_routing( + &ev, + Instant::now(), + &mut vim_state, + &mut textarea, + &mut state, + &config, + &theme, + ) == PendingInsertEscapeRouting::Consumed + { + return Ok(None); + } + if matches!(ev, Event::Paste(_)) { + keymap_runtime.clear_for_non_key(); + flush_pending_insert_escape_before_non_key( + &mut vim_state, + &mut textarea, + &mut state, + &config, + ); + } + if handle_paste_event(&ev, &mut state, &config, &mut textarea) { + vim_state.cancel_pending_command(); + return Ok(None); + } + if handle_resize_event(&ev, &mut state) { + keymap_runtime.clear_for_non_key(); + return Ok(None); + } + if matches!(ev, Event::Mouse(_)) { + keymap_runtime.clear_for_non_key(); + flush_pending_insert_escape_before_non_key( + &mut vim_state, + &mut textarea, + &mut state, + &config, + ); + } + match handle_mouse_event( + &ev, + &mut state, + &mut textarea, + Instant::now(), + ) { + MouseFlow::NotMouse => {} + MouseFlow::Handled => { + vim_state.cancel_pending_command(); + return Ok(None); + } + MouseFlow::SyntheticEnter => { + keymap_runtime.clear_for_non_key(); + vim_state.cancel_pending_command(); + // A click confirmed the focused row; run + // the exact same path a real Enter takes. + let enter_key = + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + let enter_event = Event::Key(enter_key); + let owner = input_owner_fingerprint(&state, &vim_state); + let status_flow = handle_status_key_dynamic( + &enter_event, + &enter_key, + Instant::now(), + owner, + &mut keymap_runtime, + None, + &mut state, + &mut config, + &shared_config, + &action_tx, + agent_runtime.controller(), + &preloaded_transcript, + &mut textarea, + &mut vim_state, + &theme, + initial_prompt.clone(), + || clear_terminal_scrollback(terminal), + )?; + if let Some(code) = apply_status_key_flow( + status_flow, + terminal_profile, + &mut theme, + &mut state, + ) { + return Ok(Some(code)); + } + return Ok(None); + } + } + let Event::Key(key) = &ev else { + return Ok(None); + }; + let owner = input_owner_fingerprint(&state, &vim_state); + let contextual_invocation: Option; + match handle_key_event_preflight_dynamic( + *key, + Instant::now(), + owner, + &mut keymap_runtime, + &mut state, + &mut config, + &shared_config, + &action_tx, + agent_runtime.controller(), + &mut vim_state, + || clear_terminal_scrollback(terminal), + )? { + DynamicKeyEventFlow::Continue => return Ok(None), + DynamicKeyEventFlow::Exit(code) => return Ok(Some(code)), + DynamicKeyEventFlow::Context(invocation) => { + contextual_invocation = Some(invocation); + } + DynamicKeyEventFlow::Unhandled => { + contextual_invocation = None; + } + } + + let owner = input_owner_fingerprint(&state, &vim_state); + let status_flow = handle_status_key_dynamic( + &ev, + key, + Instant::now(), + owner, + &mut keymap_runtime, + contextual_invocation, &mut state, &mut config, &shared_config, @@ -336,109 +737,331 @@ fn run_tui_inner(mut config: RunConfig) -> io::Result { &mut vim_state, &theme, initial_prompt.clone(), - || clear_terminal_scrollback(&mut terminal), - )? { + || clear_terminal_scrollback(terminal), + )?; + if let Some(code) = apply_status_key_flow( + status_flow, + terminal_profile, + &mut theme, + &mut state, + ) { return Ok(Some(code)); } return Ok(None); } - } - let Event::Key(key) = &ev else { - return Ok(None); - }; - match handle_key_event_preflight( - *key, - &mut state, - &mut config, - &shared_config, - &action_tx, - agent_runtime.controller(), - || clear_terminal_scrollback(&mut terminal), - )? { - KeyEventFlow::Continue => return Ok(None), - KeyEventFlow::Exit(code) => return Ok(Some(code)), - KeyEventFlow::Unhandled => {} - } - - if let StatusKeyFlow::Exit(code) = handle_status_key( - &ev, - key, - &mut state, - &mut config, - &shared_config, - &action_tx, - agent_runtime.controller(), - &preloaded_transcript, - &mut textarea, - &mut vim_state, - &theme, - initial_prompt.clone(), - || clear_terminal_scrollback(&mut terminal), - )? { - return Ok(Some(code)); - } - } - }, - IterationEvent::Runtime(tui_event) => match tui_event { - TuiEvent::MentionSearchDirty { generation } => { - let text = textarea_text(&textarea); - let cursor = textarea_cursor_byte_index(&textarea); - mention_search - .consume_dirty_at_cursor(generation, &text, cursor, &mut state); - } - TuiEvent::MentionCatalogDirty { generation } => { - mention_search.consume_catalog_dirty(generation, &mut state); - } - tui_event => { - handle_runtime_event( - tui_event, - &mut state, - &action_tx, - &pending_workflow_notifications, - &mut textarea, - &mut vim_state, - &theme, - ); + }, + IterationEvent::Runtime(tui_event) => match tui_event { + TuiEvent::MentionSearchDirty { generation } => { + let text = textarea_text(&textarea); + let cursor = textarea_cursor_byte_index(&textarea); + mention_search.consume_dirty_at_cursor( + generation, &text, cursor, &mut state, + ); + } + TuiEvent::MentionCatalogDirty { generation } => { + mention_search.consume_catalog_dirty(generation, &mut state); + } + tui_event => { + handle_runtime_event( + tui_event, + &mut state, + &action_tx, + &pending_workflow_notifications, + &mut textarea, + &mut vim_state, + &theme, + presentation, + ); + } + }, } + Ok(None) }, + )?; + state + .frame_metrics + .record_iteration(iteration.input_events, iteration.runtime_events); + state.sync_vim_mode(&vim_state); + let mention_enabled = MentionSearchManager::is_enabled(&state); + mention_search + .set_roots(mention_search_roots(&config, &workspace_root), &mut state); + let text = textarea_text(&textarea); + let cursor = textarea_cursor_byte_index(&textarea); + state.mention_bindings.reconcile(&text); + mention_search.sync_at_cursor( + &text, + cursor, + mention_enabled, + &mut state, + Instant::now(), + ); + if let Some(code) = iteration.exit_code { + keymap_runtime.clear_for_non_key(); + break 'main code; } - Ok(None) - }, - )?; - let mention_enabled = MentionSearchManager::is_enabled(&state); - mention_search.set_roots(mention_search_roots(&config), &mut state); - let text = textarea_text(&textarea); - let cursor = textarea_cursor_byte_index(&textarea); - state.mention_bindings.reconcile(&text); - mention_search.sync_at_cursor(&text, cursor, mention_enabled, &mut state, Instant::now()); - if let Some(code) = iteration.exit_code { - exit_code = code; - break 'main; + // A finished drag staged its text here; write it out via OSC 52 (plus + // pbcopy on macOS). The escape sequence is invisible to the UI, so no + // redraw coordination is needed. + if let Some(text) = state.pending_clipboard_copy.take() { + clipboard::copy_to_clipboard(&text); + } + let _ = + presentation.write_pending(terminal.backend_mut().inner_mut(), state.status); + if let Some(draw_at) = iteration.draw_at { + let (_, started_at, completed_at) = + measure_successful_draw(Instant::now, || { + terminal + .draw(|f| ui::render(f, &mut state, &textarea, &theme)) + .map(|_| ()) + })?; + state + .frame_metrics + .record_successful_draw(started_at, completed_at); + scheduler.did_draw(draw_at); + } + }; + Ok(exit_code) + }, + |(terminal, mut presentation, mut terminal_input)| { + finish_terminal_presentation( + terminal, + |terminal| { + let _ = presentation.write_reset_title(terminal.backend_mut().inner_mut()); + Ok(()) + }, + drop, + || terminal_input.finish(), + ) + }, + )?; + mention_search.shutdown(); + drop(event_rx); + agent_runtime.shutdown()?; + + Ok(exit_code) +} + +fn diagnostic_snapshot_for_startup( + config: &RunConfig, + terminal_identity: &qwertty::TerminalIdentity, + terminal_profile: TerminalProfile, + presentation_profile: TerminalPresentationProfile, + keybindings_location: KeybindingsLocation, +) -> DiagnosticSnapshot { + DiagnosticSnapshot::new(SnapshotInput { + app_version: &config.app_version, + terminal_identity, + terminal_profile, + presentation_profile, + requested_theme: config.theme, + resolved_theme: resolve_base_theme(config.theme, terminal_profile.background), + terminal_notifications: config.terminal_notifications, + desktop_notifications: config.desktop_notifications, + focus_events_requested: config.terminal_notifications, + vim_mode: config.vim_mode, + keybindings_location, + }) +} + +fn measure_successful_draw( + mut now: Clock, + draw: F, +) -> io::Result<(T, Instant, Instant)> +where + F: FnOnce() -> io::Result, + Clock: FnMut() -> Instant, +{ + let started_at = now(); + let value = draw()?; + let completed_at = now(); + Ok((value, started_at, completed_at)) +} + +fn resume_terminal_render( + terminal: &mut InlineTerminal, + scheduler: &mut FrameScheduler, + presentation: &mut TerminalPresentation, +) -> io::Result<()> { + complete_presentation_resume( + terminal, + Terminal::clear, + |_| presentation.invalidate_title(), + |_| scheduler.mark_dirty(), + ) +} + +fn initialize_terminal_presentation( + target: &mut T, + write_title: impl FnOnce(&mut T) -> io::Result<()>, + draw: impl FnOnce(&mut T) -> io::Result<()>, +) -> io::Result<()> { + write_title(target)?; + draw(target) +} + +fn complete_presentation_resume( + target: &mut T, + clear_terminal: impl FnOnce(&mut T) -> io::Result<()>, + invalidate_title: impl FnOnce(&mut T), + mark_dirty: impl FnOnce(&mut T), +) -> io::Result<()> { + clear_terminal(target)?; + invalidate_title(target); + mark_dirty(target); + Ok(()) +} + +fn finish_terminal_presentation( + mut terminal: T, + reset_title: impl FnOnce(&mut T) -> io::Result<()>, + drop_terminal: impl FnOnce(T), + finish_input: impl FnOnce() -> io::Result<()>, +) -> io::Result<()> { + reset_title(&mut terminal)?; + drop_terminal(terminal); + finish_input() +} + +fn with_terminal_presentation_cleanup( + mut resource: T, + body: impl FnOnce(&mut T) -> io::Result, + cleanup: impl FnOnce(T) -> io::Result<()>, +) -> io::Result { + let result = body(&mut resource); + let cleanup_result = cleanup(resource); + match result { + Err(error) => Err(error), + Ok(value) => cleanup_result.map(|()| value), + } +} + +#[cfg(test)] +fn receive_input_batch( + receiver: &mpsc::Receiver, + timeout: Duration, + limit: usize, +) -> Result, mpsc::RecvTimeoutError> { + if limit == 0 { + return Ok(Vec::new()); + } + let first = receiver.recv_timeout(timeout)?; + let mut events = Vec::with_capacity(limit.min(receiver.len().saturating_add(1))); + events.push(first); + while events.len() < limit { + match receiver.try_recv() { + Ok(event) => events.push(event), + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, } - // A finished drag staged its text here; write it out via OSC 52 (plus - // pbcopy on macOS). The escape sequence is invisible to the UI, so no - // redraw coordination is needed. - if let Some(text) = state.pending_clipboard_copy.take() { - clipboard::copy_to_clipboard(&text); + } + Ok(events) +} + +enum InputWake { + Events(Vec), + Suspend { + acknowledge: tokio::sync::oneshot::Sender<()>, + }, + Resumed, +} + +#[cfg(test)] +fn receive_input_or_control( + events: &mpsc::Receiver, + controls: &mpsc::Receiver, + timeout: Duration, + limit: usize, +) -> Result { + let timeout_rx = mpsc::after(timeout); + crossbeam_channel::select_biased! { + recv(controls) -> control => { + match control { + Ok(InputControl::Suspend { acknowledge }) => { + Ok(InputWake::Suspend { acknowledge }) + } + Ok(InputControl::Resumed) => Ok(InputWake::Resumed), + Err(_) => Err(mpsc::RecvTimeoutError::Disconnected), + } } - if let Some(draw_at) = iteration.draw_at { - terminal.draw(|f| ui::render(f, &mut state, &textarea, &theme))?; - scheduler.did_draw(draw_at); + recv(events) -> event => { + let first = event.map_err(|_| mpsc::RecvTimeoutError::Disconnected)?; + let mut batch = Vec::with_capacity(limit.max(1).min(events.len().saturating_add(1))); + if limit > 0 { + batch.push(first); + while batch.len() < limit { + match events.try_recv() { + Ok(event) => batch.push(event), + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, + } + } + } + Ok(InputWake::Events(batch)) } + recv(timeout_rx) -> _ => Err(mpsc::RecvTimeoutError::Timeout), } +} - // TerminalCleanup leaves the alternate screen (restoring the shell's - // scrollback) and unwinds raw mode / capture modes. - drop(terminal); - terminal_cleanup.finish(); - mention_search.shutdown(); - drop(event_rx); - agent_runtime.shutdown()?; +fn receive_prioritized_input_or_control( + events: &mpsc::Receiver, + focus_events: &mpsc::Receiver, + controls: &mpsc::Receiver, + timeout: Duration, + ordinary_limit: usize, +) -> Result { + let timeout_rx = mpsc::after(timeout); + crossbeam_channel::select_biased! { + recv(controls) -> control => { + match control { + Ok(InputControl::Suspend { acknowledge }) => { + Ok(InputWake::Suspend { acknowledge }) + } + Ok(InputControl::Resumed) => Ok(InputWake::Resumed), + Err(_) => Err(mpsc::RecvTimeoutError::Disconnected), + } + } + recv(focus_events) -> focus => { + let first = focus.map_err(|_| mpsc::RecvTimeoutError::Disconnected)?; + let mut batch = Vec::with_capacity(focus_events.len().saturating_add(1)); + batch.push(first); + batch.extend(focus_events.try_iter()); + for _ in 0..ordinary_limit { + match events.try_recv() { + Ok(event) => batch.push(event), + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, + } + } + Ok(InputWake::Events(batch)) + } + recv(events) -> event => { + let first = event.map_err(|_| mpsc::RecvTimeoutError::Disconnected)?; + let mut batch = Vec::with_capacity(ordinary_limit.max(1).min(events.len().saturating_add(1))); + if ordinary_limit > 0 { + batch.push(first); + while batch.len() < ordinary_limit { + match events.try_recv() { + Ok(event) => batch.push(event), + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, + } + } + } + Ok(InputWake::Events(batch)) + } + recv(timeout_rx) -> _ => Err(mpsc::RecvTimeoutError::Timeout), + } +} - Ok(exit_code) +fn poll_edit_highlight(state: &mut AppState, scheduler: &mut FrameScheduler) -> bool { + let applied = state.poll_edit_highlight_results(); + if applied { + scheduler.mark_dirty(); + } + applied +} + +fn edit_highlight_animation_active(state: &AppState) -> bool { + state.edit_highlight_needs_tick() } -fn mention_search_roots(config: &RunConfig) -> Vec { +fn mention_search_roots(config: &RunConfig, workspace_fallback: &Path) -> Vec { config .runtime_workspace_roots .as_ref() @@ -449,33 +1072,84 @@ fn mention_search_roots(config: &RunConfig) -> Vec { config .cwd .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()), + .unwrap_or_else(|| workspace_fallback.into()), ] }) } -fn shorten_home(path: &str) -> String { - if let Some(home) = std::env::var_os("HOME") { - let home = home.to_string_lossy(); - if let Some(rest) = path.strip_prefix(home.as_ref()) { - return format!("~{rest}"); - } +fn syntax_workspace_root(config: &RunConfig) -> PathBuf { + config + .cwd + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) +} + +fn configure_tui_syntax_state( + state: &mut AppState, + workspace_root: PathBuf, + syntax_theme: crate::syntax_highlight::SyntaxTheme, + syntax_color_level: crate::terminal_capabilities::TerminalColorLevel, +) { + state.configure_syntax_highlighting(workspace_root, syntax_theme, syntax_color_level); +} + +fn configure_and_preload_tui_state( + state: &mut AppState, + workspace_root: PathBuf, + syntax_theme: crate::syntax_highlight::SyntaxTheme, + syntax_color_level: crate::terminal_capabilities::TerminalColorLevel, + messages: impl IntoIterator, +) { + configure_tui_syntax_state(state, workspace_root, syntax_theme, syntax_color_level); + for message in messages { + state.push_message(message); } - path.to_string() } -type InlineTerminal = Terminal>; +type InlineTerminal = Terminal>>; + +fn clear_terminal_scrollback_with( + target: &mut T, + mut move_home: impl FnMut(&mut T) -> io::Result<()>, + mut clear_all: impl FnMut(&mut T) -> io::Result<()>, + mut clear_purge: impl FnMut(&mut T) -> io::Result<()>, + mut clear_frame: impl FnMut(&mut T) -> io::Result<()>, +) -> io::Result<()> { + move_home(target)?; + clear_all(target)?; + clear_purge(target)?; + clear_frame(target) +} /// Erase the native scrollback and on-screen content. Used by the clear-screen shortcut so a /// fresh session starts on a clean terminal instead of stacking under the old transcript. fn clear_terminal_scrollback(terminal: &mut InlineTerminal) -> io::Result<()> { use crossterm::terminal::{Clear, ClearType}; - let stdout = terminal.backend_mut(); - stdout.execute(crossterm::cursor::MoveTo(0, 0))?; - stdout.execute(Clear(ClearType::All))?; - stdout.execute(Clear(ClearType::Purge))?; - terminal.clear()?; - Ok(()) + clear_terminal_scrollback_with( + terminal, + |terminal| { + terminal + .backend_mut() + .inner_mut() + .execute(crossterm::cursor::MoveTo(0, 0))?; + Ok(()) + }, + |terminal| { + terminal + .backend_mut() + .inner_mut() + .execute(Clear(ClearType::All))?; + Ok(()) + }, + |terminal| { + terminal + .backend_mut() + .inner_mut() + .execute(Clear(ClearType::Purge))?; + Ok(()) + }, + Terminal::clear, + ) } #[cfg(test)] @@ -561,40 +1235,1522 @@ fn run_hosted_tui_controller_for_test( runtime.shutdown().expect("hosted TUI test shutdown"); } -fn now_timestamp() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or_default() -} +fn now_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyCode; + use orca_core::approval_types::ApprovalMode; + use orca_core::model::ModelSelection; + use tui_textarea::TextArea; + + use crate::approval_actions::resolve_approval_option; + use crate::commands; + use crate::composer_textarea::{ + insert_composer_paste, insert_pasted_text, make_textarea_with_text, textarea_text, + }; + use crate::idle_submit_actions::handle_idle_submit; + use crate::key_event_actions::handle_transcript_search_key; + use crate::selection::{SelectionGranularity, SelectionPos, TranscriptSelection}; + use crate::slash_command_actions::handle_slash_command; + use crate::types::{ApprovalOption, PendingTuiInput, SlashMenu, SlashMenuItem, SubMenu}; + use crate::types::{TuiInteractionKey, TuiInteractionKind, TuiInteractionResponse}; + use crate::workflow_notifications::drain_pending_workflow_notifications; + + fn production_app_source() -> &'static str { + include_str!("app.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production app source") + } + + #[test] + fn startup_captures_one_identity_for_presentation_and_diagnostics() { + let production = production_app_source(); + assert_eq!( + production + .matches("qwertty::caps::identity_from_env(") + .count(), + 1, + ); + let identity = production.find("let terminal_identity =").unwrap(); + let presentation = production + .find("TerminalPresentationProfile::from_identity(&terminal_identity)") + .unwrap(); + let diagnostics = production.find("DiagnosticSnapshot::new(").unwrap(); + assert!(identity < presentation && identity < diagnostics); + } + + #[test] + fn production_diagnostics_use_effective_profile_without_reprobe() { + let production = production_app_source(); + assert_eq!(production.matches("InputRuntime::start").count(), 1); + assert!(!production.contains("probe_capabilities(")); + assert!(!production.contains("probe_background(")); + } + + #[test] + fn onboarding_theme_preview_reuses_captured_profile_without_reprobe() { + let source = production_app_source(); + let helper = source + .find("fn apply_onboarding_theme_preview(") + .expect("preview helper"); + let body = &source[helper + ..source[helper + 1..] + .find("\nfn ") + .map(|offset| helper + 1 + offset) + .unwrap_or(source.len())]; + + assert!(body.contains("Theme::resolve(requested, terminal_profile)")); + assert!(!body.contains("InputRuntime::start")); + assert!(!body.contains("identity_from_env")); + assert!(!body.contains("system_color_level")); + assert!(!body.contains("probe_")); + assert!(!body.contains("CapabilityBackend::new")); + } + + #[test] + fn preview_updates_theme_syntax_revision_and_doctor_projection_together() { + use crate::syntax_highlight::SyntaxTheme; + use crate::terminal_capabilities::{ + TerminalBackground, TerminalColorLevel, TerminalProfile, syntax_style_revision, + }; + + let profile = TerminalProfile { + background: TerminalBackground::Light, + color_level: TerminalColorLevel::Ansi256, + }; + let (tx, _rx) = mpsc::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "auto".to_string(), + "/tmp".to_string(), + ); + let mut theme = Theme::resolve(ThemeName::Dark, profile); + + apply_onboarding_theme_preview(ThemeName::Auto, profile, &mut theme, &mut state); + + assert_eq!(theme.color_level, TerminalColorLevel::Ansi256); + assert_eq!(theme.syntax_theme, SyntaxTheme::OneHalfLight); + assert_eq!(state.syntax_theme_for_test(), SyntaxTheme::OneHalfLight); + assert_eq!( + syntax_style_revision( + state.syntax_theme_for_test(), + state.syntax_color_level_for_test(), + ), + theme.syntax_theme_revision, + ); + assert_eq!(state.diagnostics.requested_theme(), ThemeName::Auto); + assert_eq!(state.diagnostics.resolved_theme(), ThemeName::Light); + } + + #[test] + fn preview_preserves_each_captured_color_level_and_auto_background() { + use crate::terminal_capabilities::{ + TerminalBackground, TerminalColorLevel, TerminalProfile, + }; + + for color_level in [ + TerminalColorLevel::TrueColor, + TerminalColorLevel::Ansi256, + TerminalColorLevel::Ansi16, + TerminalColorLevel::Monochrome, + ] { + for (background, resolved) in [ + (TerminalBackground::Light, ThemeName::Light), + (TerminalBackground::Dark, ThemeName::Dark), + (TerminalBackground::Unknown, ThemeName::Dark), + ] { + let profile = TerminalProfile { + background, + color_level, + }; + let (tx, _rx) = mpsc::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "auto".to_string(), + "/tmp".to_string(), + ); + let mut theme = Theme::resolve(ThemeName::Catppuccin, profile); + + apply_onboarding_theme_preview(ThemeName::Auto, profile, &mut theme, &mut state); + + assert_eq!(theme.color_level, color_level); + assert_eq!(state.syntax_color_level_for_test(), color_level); + assert_eq!(state.diagnostics.requested_theme(), ThemeName::Auto); + assert_eq!(state.diagnostics.resolved_theme(), resolved); + } + } + } + + #[test] + fn complete_enter_builds_main_textarea_from_current_previewed_theme() { + use crate::onboarding::OnboardingStep; + use crate::terminal_capabilities::{ + TerminalBackground, TerminalColorLevel, TerminalProfile, + }; + + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "auto".to_string(), + "/tmp".to_string(), + ); + state.status = AppStatus::Setup; + state.onboarding.set_step_for_test(OnboardingStep::Complete); + let mut config = test_config(HistoryMode::Disabled); + config.theme = ThemeName::Dark; + let shared = Arc::new(Mutex::new(config.clone())); + let operation = crate::test_support::TestOperationInterrupt::default(); + let preloaded = Arc::new(Mutex::new(None)); + let previewed = Theme::resolve( + ThemeName::Light, + TerminalProfile { + background: TerminalBackground::Dark, + color_level: TerminalColorLevel::Ansi256, + }, + ); + let startup = Theme::resolve( + ThemeName::Dark, + TerminalProfile { + background: TerminalBackground::Dark, + color_level: TerminalColorLevel::Ansi256, + }, + ); + let mut textarea = make_setup_textarea(&startup); + let mut vim = VimState::new(false); + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + + let flow = handle_status_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &previewed, + None, + || Ok(()), + ) + .unwrap(); + + assert!(matches!(flow, StatusKeyFlow::Continue)); + assert_eq!(state.status, AppStatus::Idle); + assert_eq!( + textarea.cursor_style(), + make_textarea(&vim, &previewed).cursor_style(), + ); + assert_ne!( + textarea.cursor_style(), + make_textarea(&vim, &startup).cursor_style(), + ); + } + + fn status_flow_harness() -> (TerminalProfile, Theme, AppState) { + use crate::terminal_capabilities::{ + TerminalBackground, TerminalColorLevel, TerminalProfile, + }; + + let profile = TerminalProfile { + background: TerminalBackground::Light, + color_level: TerminalColorLevel::Ansi16, + }; + let (tx, _rx) = mpsc::unbounded(); + let state = AppState::new( + tx, + "test".to_string(), + "auto".to_string(), + "/tmp".to_string(), + ); + let theme = Theme::resolve(ThemeName::Dark, profile); + (profile, theme, state) + } + + #[test] + fn status_key_flow_continue_preserves_theme_projection() { + let (profile, mut theme, mut state) = status_flow_harness(); + let syntax_theme = theme.syntax_theme; + let syntax_color_level = state.syntax_color_level_for_test(); + + let exit = apply_status_key_flow(StatusKeyFlow::Continue, profile, &mut theme, &mut state); + + assert_eq!(exit, None); + assert_eq!(theme.syntax_theme, syntax_theme); + assert_eq!(state.syntax_color_level_for_test(), syntax_color_level); + assert_eq!(state.diagnostics.requested_theme(), ThemeName::Auto); + assert_eq!(state.diagnostics.resolved_theme(), ThemeName::Dark); + } + + #[test] + fn status_key_flow_preview_updates_theme_and_projections_without_exit() { + let (profile, mut theme, mut state) = status_flow_harness(); + + let exit = apply_status_key_flow( + StatusKeyFlow::PreviewTheme(ThemeName::Auto), + profile, + &mut theme, + &mut state, + ); + + assert_eq!(exit, None); + assert_eq!( + theme.syntax_theme, + crate::syntax_highlight::SyntaxTheme::OneHalfLight + ); + assert_eq!(state.diagnostics.requested_theme(), ThemeName::Auto); + assert_eq!(state.diagnostics.resolved_theme(), ThemeName::Light); + } + + #[test] + fn status_key_flow_exit_returns_code_without_mutating_projection() { + let (profile, mut theme, mut state) = status_flow_harness(); + let syntax_theme = theme.syntax_theme; + let syntax_color_level = state.syntax_color_level_for_test(); + + let exit = apply_status_key_flow(StatusKeyFlow::Exit(23), profile, &mut theme, &mut state); + + assert_eq!(exit, Some(23)); + assert_eq!(theme.syntax_theme, syntax_theme); + assert_eq!(state.syntax_color_level_for_test(), syntax_color_level); + assert_eq!(state.diagnostics.requested_theme(), ThemeName::Auto); + assert_eq!(state.diagnostics.resolved_theme(), ThemeName::Dark); + } + + #[test] + fn startup_snapshot_projects_effective_profile_and_orca_home_location() { + use crate::diagnostics::KeybindingsLocation; + use crate::terminal_capabilities::{ + TerminalBackground, TerminalColorLevel, TerminalProfile, + }; + + let identity = qwertty::caps::identity_from_env(None, |key| match key { + "TERM_PROGRAM" => Some("ghostty".to_string()), + "TMUX" => Some("session".to_string()), + _ => None, + }); + let snapshot = diagnostic_snapshot_for_startup( + &test_config(HistoryMode::Disabled), + &identity, + TerminalProfile { + background: TerminalBackground::Dark, + color_level: TerminalColorLevel::Ansi256, + }, + TerminalPresentationProfile::from_identity(&identity), + KeybindingsLocation::OrcaHome, + ); + + assert_eq!(snapshot.terminal_program(), "Ghostty"); + assert_eq!(snapshot.multiplexers(), ["tmux"]); + assert_eq!(snapshot.color_level(), TerminalColorLevel::Ansi256); + assert_eq!(snapshot.requested_theme(), ThemeName::Dark); + assert_eq!(snapshot.resolved_theme(), ThemeName::Dark); + assert_eq!( + snapshot.keybindings_location(), + KeybindingsLocation::OrcaHome, + ); + } + + #[test] + fn doctor_vim_projection_tracks_real_mode_transitions() { + use crate::vim::VimMode; + + let theme = Theme::named(ThemeName::Dark); + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + + let disabled = VimState::new(false); + state.sync_vim_mode(&disabled); + assert_eq!(state.vim_mode, None); + + let mut vim = VimState::new(true); + let mut textarea = make_textarea_with_text("word", &vim, &theme); + state.sync_vim_mode(&vim); + assert_eq!(state.vim_mode, Some(VimMode::Normal)); + + for (key, expected) in [ + (KeyCode::Char('i'), VimMode::Insert), + (KeyCode::Esc, VimMode::Normal), + (KeyCode::Char('v'), VimMode::Visual), + (KeyCode::Esc, VimMode::Normal), + ] { + let event = Event::Key(KeyEvent::new(key, KeyModifiers::NONE)); + vim.handle(Input::from(event), &mut textarea, &theme); + state.sync_vim_mode(&vim); + assert_eq!(state.vim_mode, Some(expected)); + } + } + + #[test] + fn vim_projection_sync_is_owned_by_app_not_leaf_handlers() { + let app = production_app_source(); + assert!(app.contains("state.sync_vim_mode(&vim_state)")); + for source in [ + include_str!("slash_command_actions.rs"), + include_str!("idle_key_actions.rs"), + include_str!("queued_input_actions.rs"), + include_str!("mention_menu_actions.rs"), + include_str!("slash_menu_actions.rs"), + ] { + assert!(!source.contains("sync_vim_mode(")); + } + } + + #[test] + fn successful_draw_records_once_and_failed_draw_records_nothing() { + use std::collections::VecDeque; + + let start = Instant::now(); + let mut metrics = crate::diagnostics::FrameMetrics::default(); + let mut times = VecDeque::from([ + start, + start + Duration::from_millis(3), + start + Duration::from_millis(10), + start + Duration::from_millis(11), + ]); + let (_, started, completed) = + measure_successful_draw(|| times.pop_front().unwrap(), || Ok(())).unwrap(); + metrics.record_successful_draw(started, completed); + measure_successful_draw( + || times.pop_front().unwrap(), + || Err::<(), _>(io::Error::other("draw failed")), + ) + .unwrap_err(); + + assert_eq!( + metrics + .snapshot(start + Duration::from_millis(11)) + .total_draws, + 1, + ); + } + + #[test] + fn production_draws_and_iteration_counts_are_recorded_once() { + let source = production_app_source(); + assert_eq!(source.matches("measure_successful_draw(").count(), 2); + assert_eq!(source.matches(".record_successful_draw(").count(), 2); + assert_eq!(source.matches(".record_iteration(").count(), 1); + } + + #[test] + fn doctor_suspend_resets_rolling_before_acknowledgement() { + let source = production_app_source(); + let suspend = source.find("InputWake::Suspend").unwrap(); + let reset = source[suspend..] + .find("frame_metrics.reset_rolling()") + .unwrap(); + let acknowledge = source[suspend..].find("acknowledge.send").unwrap(); + assert!(reset < acknowledge); + } + + #[test] + fn fps_hud_controls_animation_without_changing_frame_interval() { + let source = production_app_source(); + let animation = source.find("let animation_active =").unwrap(); + let receive = source.find("receive_prioritized_input_or_control").unwrap(); + assert!(source[animation..receive].contains("state.fps_hud_enabled")); + assert_eq!(source.matches("const FRAME_INTERVAL:").count(), 1); + assert!(source.contains("Duration::from_millis(16)")); + } + + #[test] + fn doctor_keybindings_projection_is_wired_to_reload_outcomes() { + let source = include_str!("app.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production app source"); + assert!(source.contains("KeybindingsDiagnostic::built_ins(")); + assert!(source.contains(".applied_custom(")); + assert!(source.contains(".restored_built_ins(")); + assert!(source.contains(".rejected(")); + assert!(source.contains("keybindings_location()")); + } + + #[test] + fn doctor_auth_projection_is_initialized_and_updated_from_config_facts() { + let app = include_str!("app.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production app source"); + let setup = include_str!("setup_actions.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production setup source"); + assert!(app.contains("state.auth_configured = config.api_key.is_some();")); + assert!(setup.contains("state.auth_configured = true;")); + } + + #[test] + fn keybinding_owner_tracks_status_modal_panel_and_vim_mode() { + let (tx, _rx) = mpsc::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut vim = VimState::new(true); + + let idle = input_owner_fingerprint(&state, &vim); + assert_eq!(idle.context, crate::shortcuts::ShortcutContext::Idle); + assert_eq!(idle.modal, crate::keybindings::ModalOwner::None); + assert_eq!(idle.vim_mode, Some(crate::vim::VimMode::Normal)); + + state.show_shortcuts = true; + assert_eq!( + input_owner_fingerprint(&state, &vim).modal, + crate::keybindings::ModalOwner::Shortcuts, + ); + state.show_shortcuts = false; + state.open_transcript_search(); + assert_eq!( + input_owner_fingerprint(&state, &vim).modal, + crate::keybindings::ModalOwner::TranscriptSearch, + ); + state.close_transcript_search(); + state.set_status(AppStatus::WaitingApproval); + assert_eq!( + input_owner_fingerprint(&state, &vim).context, + crate::shortcuts::ShortcutContext::Approval, + ); + assert_eq!( + input_owner_fingerprint(&state, &vim).modal, + crate::keybindings::ModalOwner::Approval, + ); + vim.mode = crate::vim::VimMode::Insert; + assert_eq!( + input_owner_fingerprint(&state, &vim).vim_mode, + Some(crate::vim::VimMode::Insert), + ); + } + + #[test] + fn chord_deadline_caps_frame_poll_timeout() { + let now = Instant::now(); + assert_eq!( + keybinding_poll_timeout( + Duration::from_millis(16), + now, + Some(now + Duration::from_millis(5)), + ), + Duration::from_millis(5), + ); + assert_eq!( + keybinding_poll_timeout(Duration::from_millis(16), now, Some(now)), + Duration::ZERO, + ); + assert_eq!( + keybinding_poll_timeout(Duration::from_millis(16), now, None), + Duration::from_millis(16), + ); + } + + #[test] + fn shared_global_and_idle_prefixes_are_both_reachable_through_app_routing() { + let keymap = crate::keybindings::parse_keymap( + br#"{ + "version": 1, + "bindings": { + "global.open-transcript-search": ["ctrl+x ctrl+f"], + "idle.submit": ["ctrl+x ctrl+s"] + } + }"#, + ) + .unwrap(); + let now = Instant::now(); + let operation = crate::test_support::TestOperationInterrupt::default(); + let theme = Theme::named(ThemeName::Dark); + + let (search_tx, _search_rx) = mpsc::unbounded(); + let mut search_state = AppState::new( + search_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut search_config = test_config(HistoryMode::Disabled); + let search_shared = Arc::new(Mutex::new(search_config.clone())); + let mut search_vim = VimState::new(false); + let mut search_runtime = KeymapRuntime::new(Arc::clone(&keymap)); + let owner = input_owner_fingerprint(&search_state, &search_vim); + for (offset, character) in [(0, 'x'), (1, 'f')] { + let key = KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL); + assert!(matches!( + handle_key_event_preflight_dynamic( + key, + now + Duration::from_millis(offset), + owner, + &mut search_runtime, + &mut search_state, + &mut search_config, + &search_shared, + &search_tx, + &operation, + &mut search_vim, + || Ok(()), + ) + .unwrap(), + DynamicKeyEventFlow::Continue, + )); + } + assert!(search_state.transcript_search.open); + + let (submit_tx, submit_rx) = mpsc::unbounded(); + let mut submit_state = AppState::new( + submit_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut submit_config = test_config(HistoryMode::Disabled); + let submit_shared = Arc::new(Mutex::new(submit_config.clone())); + let mut submit_vim = VimState::new(false); + let mut submit_runtime = KeymapRuntime::new(keymap); + let mut textarea = make_textarea_with_text("send me", &submit_vim, &theme); + let preloaded = Arc::new(Mutex::new(None)); + let owner = input_owner_fingerprint(&submit_state, &submit_vim); + let prefix = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + assert!(matches!( + handle_key_event_preflight_dynamic( + prefix, + now, + owner, + &mut submit_runtime, + &mut submit_state, + &mut submit_config, + &submit_shared, + &submit_tx, + &operation, + &mut submit_vim, + || Ok(()), + ) + .unwrap(), + DynamicKeyEventFlow::Continue, + )); + let suffix = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL); + let DynamicKeyEventFlow::Context(invocation) = handle_key_event_preflight_dynamic( + suffix, + now + Duration::from_millis(1), + owner, + &mut submit_runtime, + &mut submit_state, + &mut submit_config, + &submit_shared, + &submit_tx, + &operation, + &mut submit_vim, + || Ok(()), + ) + .unwrap() else { + panic!("idle chord must complete as a contextual invocation"); + }; + handle_status_key_dynamic( + &Event::Key(suffix), + &suffix, + now + Duration::from_millis(1), + owner, + &mut submit_runtime, + Some(invocation), + &mut submit_state, + &mut submit_config, + &submit_shared, + &submit_tx, + &operation, + &preloaded, + &mut textarea, + &mut submit_vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + assert!(matches!( + submit_rx.try_recv(), + Ok(UserAction::SubmitWithMentions { prompt, .. }) if prompt == "send me" + )); + } + + #[test] + fn contextual_chord_mismatch_retries_current_key_exactly_once() { + let keymap = crate::keybindings::parse_keymap( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+x ctrl+s"]}}"#, + ) + .unwrap(); + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = test_config(HistoryMode::Disabled); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = crate::test_support::TestOperationInterrupt::default(); + let preloaded = Arc::new(Mutex::new(None)); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("base", &vim, &theme); + let mut runtime = KeymapRuntime::new(keymap); + let owner = input_owner_fingerprint(&state, &vim); + let now = Instant::now(); + + let prefix = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + assert!(matches!( + handle_key_event_preflight_dynamic( + prefix, + now, + owner, + &mut runtime, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(), + DynamicKeyEventFlow::Unhandled, + )); + handle_status_key_dynamic( + &Event::Key(prefix), + &prefix, + now, + owner, + &mut runtime, + None, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + + let mismatch = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); + assert!(matches!( + handle_key_event_preflight_dynamic( + mismatch, + now + Duration::from_millis(1), + owner, + &mut runtime, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(), + DynamicKeyEventFlow::Unhandled, + )); + handle_status_key_dynamic( + &Event::Key(mismatch), + &mismatch, + now + Duration::from_millis(1), + owner, + &mut runtime, + None, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + + assert_eq!(textarea_text(&textarea), "basez"); + } + use crate::workflow_notifications::{ + is_workflow_notification_turn_boundary, queue_workflow_terminal_notification, + remove_pending_workflow_notification_by_id, submit_pending_workflow_notification, + }; + use crate::workflow_panel_actions::handle_workflows_panel_key; + use orca_core::config::{ + ModelRuntimeConfig, OutputFormat, ProviderKind, ThemeName, ToolConfig, + VimInsertEscapeSequence, WorkflowConfig, + }; + use tempfile::tempdir; + + fn vim_insert_input(character: char) -> tui_textarea::Input { + tui_textarea::Input { + key: tui_textarea::Key::Char(character), + ctrl: false, + alt: false, + shift: false, + } + } + + fn inserted_source_line<'a>( + lines: &'a [ratatui::text::Line<'static>], + source: &str, + ) -> &'a ratatui::text::Line<'static> { + lines + .iter() + .find(|line| { + line.to_string().contains(source) + && line + .spans + .first() + .is_some_and(|span| span.content.ends_with("+ ")) + }) + .unwrap_or_else(|| panic!("inserted source line containing {source:?}")) + } + + #[test] + fn receive_input_batch_waits_drains_and_caps() { + let (sender, receiver) = mpsc::bounded(128); + for character in 'a'..='z' { + sender + .send(Event::Key(KeyEvent::new( + KeyCode::Char(character), + KeyModifiers::NONE, + ))) + .expect("receiver alive"); + } + + let first = receive_input_batch(&receiver, Duration::from_millis(10), 5) + .expect("queued input should be received"); + assert_eq!(first.len(), 5); + assert_eq!(receiver.len(), 21); + + let remaining = receive_input_batch(&receiver, Duration::from_millis(10), 64) + .expect("remaining queued input should be received"); + assert_eq!(remaining.len(), 21); + assert!(receiver.is_empty()); + + sender + .send(Event::Key(KeyEvent::new( + KeyCode::Char('!'), + KeyModifiers::NONE, + ))) + .expect("receiver alive"); + assert_eq!( + receive_input_batch(&receiver, Duration::from_millis(10), 0), + Ok(Vec::new()) + ); + assert_eq!(receiver.len(), 1); + } + + #[test] + fn pending_insert_escape_preflight_precedes_shortcuts_only_after_sequence_started() { + let theme = Theme::named(ThemeName::Dark); + let sequence = VimInsertEscapeSequence::parse("jj").unwrap(); + let started = Instant::now(); + let mut vim = VimState::with_insert_escape(true, Some(sequence)); + vim.mode = crate::vim::VimMode::Insert; + let mut textarea = TextArea::default(); + let mut state = test_state().0; + let config = test_config(HistoryMode::Disabled); + + let first = Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!( + resolve_pending_insert_escape_before_routing( + &first, + started, + &mut vim, + &mut textarea, + &mut state, + &config, + &theme, + ), + PendingInsertEscapeRouting::Continue, + ); + vim.handle_at(vim_insert_input('j'), &mut textarea, &theme, started); + + let second = Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!( + resolve_pending_insert_escape_before_routing( + &second, + started + Duration::from_millis(1), + &mut vim, + &mut textarea, + &mut state, + &config, + &theme, + ), + PendingInsertEscapeRouting::Consumed, + ); + assert_eq!(vim.mode, crate::vim::VimMode::Normal); + assert!(textarea.is_empty()); + } + + #[test] + fn pending_insert_escape_flushes_before_submit_and_paste_ownership() { + let theme = Theme::named(ThemeName::Dark); + let started = Instant::now(); + let sequence = VimInsertEscapeSequence::parse("jj").unwrap(); + + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = test_config(HistoryMode::Disabled); + let shared = Arc::new(Mutex::new(config.clone())); + let mut vim = VimState::with_insert_escape(true, Some(sequence.clone())); + vim.mode = crate::vim::VimMode::Insert; + let mut textarea = TextArea::default(); + vim.handle_at(vim_insert_input('j'), &mut textarea, &theme, started); + + let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + resolve_pending_insert_escape_before_routing( + &enter, + started + Duration::from_millis(1), + &mut vim, + &mut textarea, + &mut state, + &config, + &theme, + ), + PendingInsertEscapeRouting::Continue, + ); + assert!(handle_idle_submit( + &mut textarea, + &mut vim, + &theme, + &mut state, + &mut config, + &shared, + &action_tx, + )); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitWithMentions { prompt, .. }) if prompt == "j" + )); + + let mut paste_state = test_state().0; + let paste_config = test_config(HistoryMode::Disabled); + let mut paste_vim = VimState::with_insert_escape(true, Some(sequence)); + paste_vim.mode = crate::vim::VimMode::Insert; + let mut paste_area = TextArea::default(); + paste_vim.handle_at(vim_insert_input('j'), &mut paste_area, &theme, started); + assert!(flush_pending_insert_escape_before_non_key( + &mut paste_vim, + &mut paste_area, + &mut paste_state, + &paste_config, + )); + assert!(handle_paste_event( + &Event::Paste("jj".to_string()), + &mut paste_state, + &paste_config, + &mut paste_area, + )); + assert_eq!(textarea_text(&paste_area), "jjj"); + } + + #[test] + fn pending_insert_escape_flushes_before_running_escape_interrupt() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + let mut config = test_config(HistoryMode::Disabled); + config.vim_mode = true; + config.vim_insert_escape = Some(VimInsertEscapeSequence::parse("jj").unwrap()); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = crate::test_support::TestOperationInterrupt::default(); + let preloaded = Arc::new(Mutex::new(None)); + let theme = Theme::named(ThemeName::Dark); + let started = Instant::now(); + let mut vim = VimState::with_insert_escape(true, config.vim_insert_escape.clone()); + vim.mode = crate::vim::VimMode::Insert; + let mut textarea = TextArea::default(); + vim.handle_at(vim_insert_input('j'), &mut textarea, &theme, started); + let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + let event = Event::Key(key); + + assert_eq!( + resolve_pending_insert_escape_before_routing( + &event, + started + Duration::from_millis(1), + &mut vim, + &mut textarea, + &mut state, + &config, + &theme, + ), + PendingInsertEscapeRouting::Continue, + ); + handle_status_key( + &event, + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + + assert_eq!(textarea_text(&textarea), "j"); + assert_eq!(state.status, AppStatus::Running); + assert_eq!(operation.call_count(), 1); + assert!(matches!(action_rx.try_recv(), Ok(UserAction::Interrupt))); + } + + #[test] + fn expired_insert_escape_flush_refreshes_input_state_once() { + let theme = Theme::named(ThemeName::Dark); + let config = test_config(HistoryMode::Disabled); + let started = Instant::now(); + let mut vim = + VimState::with_insert_escape(true, Some(VimInsertEscapeSequence::parse("jj").unwrap())); + vim.mode = crate::vim::VimMode::Insert; + let mut textarea = TextArea::default(); + let mut state = test_state().0; + vim.handle_at(vim_insert_input('j'), &mut textarea, &theme, started); + + assert!(flush_expired_insert_escape( + started + Duration::from_millis(501), + &mut vim, + &mut textarea, + &mut state, + &config, + )); + assert_eq!(textarea_text(&textarea), "j"); + assert!(!vim.has_pending_insert_escape_for_test()); + } + + #[test] + fn receive_input_batch_reports_timeout_and_disconnect() { + let (sender, receiver) = mpsc::bounded(1); + assert_eq!( + receive_input_batch(&receiver, Duration::from_millis(1), 64), + Err(mpsc::RecvTimeoutError::Timeout) + ); + drop(sender); + assert_eq!( + receive_input_batch(&receiver, Duration::from_millis(1), 64), + Err(mpsc::RecvTimeoutError::Disconnected) + ); + } + + #[test] + fn receive_input_or_control_prioritizes_suspend_over_queued_keys() { + let (event_tx, event_rx) = mpsc::bounded(1); + event_tx + .send(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))) + .expect("event receiver alive"); + let (control_tx, control_rx) = mpsc::bounded(1); + let (acknowledge, acknowledged) = tokio::sync::oneshot::channel(); + control_tx + .send(InputControl::Suspend { acknowledge }) + .expect("control receiver alive"); + + let wake = receive_input_or_control(&event_rx, &control_rx, Duration::from_millis(10), 64) + .expect("suspend control should win"); + let InputWake::Suspend { acknowledge } = wake else { + panic!("expected suspend control"); + }; + acknowledge + .send(()) + .expect("acknowledgement receiver alive"); + assert_eq!( + acknowledged.blocking_recv(), + Ok(()), + "input owner receives the frame-loop acknowledgement" + ); + assert_eq!(event_rx.len(), 1, "queued key waits until resume"); + } + + #[test] + fn receive_input_or_control_prioritizes_focus_beyond_the_ordinary_input_cap() { + for focus in [Event::FocusLost, Event::FocusGained] { + let (event_tx, event_rx) = mpsc::bounded(128); + for _ in 0..65 { + event_tx + .send(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))) + .expect("event receiver alive"); + } + let (focus_tx, focus_rx) = mpsc::bounded(8); + focus_tx.send(focus.clone()).expect("focus receiver alive"); + let (_control_tx, control_rx) = mpsc::bounded(1); + + let wake = receive_prioritized_input_or_control( + &event_rx, + &focus_rx, + &control_rx, + Duration::from_millis(10), + 64, + ) + .expect("queued input should be received"); + let InputWake::Events(events) = wake else { + panic!("expected input events"); + }; + + let started = Instant::now(); + let mut scheduler = FrameScheduler::new( + started, + Duration::from_millis(16), + Duration::from_millis(80), + ); + scheduler.did_draw(started); + let mut handled_focus = false; + let mut handled_keys = 0; + run_event_loop_iteration( + &mut scheduler, + events, + std::iter::empty::<()>(), + usize::MAX, + 0, + || started, + |event| { + if let IterationEvent::Input(event) = event { + match event { + Event::FocusLost | Event::FocusGained => handled_focus = true, + Event::Key(_) => handled_keys += 1, + _ => {} + } + } + Ok::, ()>(None) + }, + ) + .expect("prioritized iteration"); + + assert!( + handled_focus, + "focus changes must bypass the ordinary input cap" + ); + assert_eq!(handled_keys, 64); + assert_eq!(event_rx.len(), 1, "ordinary overflow remains queued"); + + let next = receive_prioritized_input_or_control( + &event_rx, + &focus_rx, + &control_rx, + Duration::ZERO, + 64, + ) + .expect("queued overflow should be returned without waiting"); + let InputWake::Events(next) = next else { + panic!("expected queued overflow"); + }; + assert!(matches!(next.as_slice(), [Event::Key(_)])); + assert!(event_rx.is_empty()); + } + } + + #[test] + fn prioritized_focus_preserves_bounded_ordinary_input_backpressure() { + let (event_tx, event_rx) = mpsc::bounded(128); + let (focus_tx, focus_rx) = mpsc::bounded(8); + let (_control_tx, control_rx) = mpsc::bounded(1); + + for _ in 0..3 { + while event_tx.len() < 128 { + event_tx + .send(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))) + .expect("event receiver alive"); + } + focus_tx + .send(Event::FocusLost) + .expect("focus receiver alive"); + let wake = receive_prioritized_input_or_control( + &event_rx, + &focus_rx, + &control_rx, + Duration::ZERO, + 64, + ) + .expect("queued input should be received"); + assert!(matches!(wake, InputWake::Events(_))); + } + + assert_eq!(event_rx.len(), 64); + assert!(focus_rx.is_empty()); + } + + #[test] + fn terminal_title_writes_before_initial_draw() { + let mut calls = Vec::new(); + initialize_terminal_presentation( + &mut calls, + |calls| { + calls.push("write-start"); + Ok(()) + }, + |calls| { + calls.push("draw-start"); + Ok(()) + }, + ) + .expect("startup presentation"); + assert_eq!(calls, ["write-start", "draw-start"]); + } + + #[test] + fn presentation_resume_clears_invalidates_then_marks_dirty() { + let mut calls = Vec::new(); + complete_presentation_resume( + &mut calls, + |calls| { + calls.push("clear"); + Ok(()) + }, + |calls| calls.push("invalidate"), + |calls| calls.push("dirty"), + ) + .expect("resume presentation"); + assert_eq!(calls, ["clear", "invalidate", "dirty"]); + + let mut calls = Vec::new(); + let error = complete_presentation_resume( + &mut calls, + |_| Err(io::Error::other("clear failed")), + |calls| calls.push("invalidate"), + |calls| calls.push("dirty"), + ) + .expect_err("clear failure should stop resume"); + assert_eq!(error.to_string(), "clear failed"); + assert!(calls.is_empty()); + } + + #[test] + fn presentation_exit_resets_drops_then_finishes_input() { + let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let reset_exit = std::rc::Rc::clone(&calls); + let drop_exit = std::rc::Rc::clone(&calls); + let finish_exit = std::rc::Rc::clone(&calls); + finish_terminal_presentation( + (), + move |_| { + reset_exit.borrow_mut().push("reset"); + Ok(()) + }, + move |_| drop_exit.borrow_mut().push("drop"), + move || { + finish_exit.borrow_mut().push("finish"); + Ok(()) + }, + ) + .expect("exit presentation"); + assert_eq!(*calls.borrow(), ["reset", "drop", "finish"]); + } + + #[test] + fn presentation_exit_cleanup_runs_after_body_error() { + let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let body = std::rc::Rc::clone(&calls); + let cleanup = std::rc::Rc::clone(&calls); + + let error = with_terminal_presentation_cleanup( + (), + move |_| { + body.borrow_mut().push("body"); + Err::(io::Error::other("body failed")) + }, + move |_| { + cleanup.borrow_mut().push("cleanup"); + Ok(()) + }, + ) + .expect_err("body error should be preserved"); + + assert_eq!(error.to_string(), "body failed"); + assert_eq!(*calls.borrow(), ["body", "cleanup"]); + } + + #[test] + fn terminal_input_ownership_is_single() { + let production = include_str!("app.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production source before tests"); + + for forbidden in [ + "event::poll", + "event::read", + "EventStream", + "enable_raw_mode", + "EnterAlternateScreen", + "EnableMouseCapture", + "EnableBracketedPaste", + "PushKeyboardEnhancementFlags", + "TerminalCleanup", + ] { + assert!( + !production.contains(forbidden), + "production app must not own terminal input/mode operation {forbidden}" + ); + } + + let start = production + .find("InputRuntime::start") + .expect("qwertty input starts"); + let terminal = production + .find("Terminal::new") + .expect("ratatui terminal is constructed"); + assert!(start < terminal); + assert_eq!(production.matches("InputRuntime::start").count(), 1); + assert_eq!(production.matches("Terminal::new").count(), 1); + } + + #[test] + fn non_composer_input_boundaries_cancel_pending_vim_commands() { + let production = include_str!("app.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production app source"); + + assert!( + production + .matches("vim_state.cancel_pending_command()") + .count() + >= 4 + ); + assert!(production.contains( + "&mut vim_state,\n || clear_terminal_scrollback" + )); + } + + #[test] + fn startup_captures_workspace_status_once_before_frame_loop() { + let production = include_str!("app.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production source before tests"); + assert_eq!( + production + .matches("workspace_status::snapshot(&workspace_root)") + .count(), + 1 + ); + let snapshot = production + .find("workspace_status::snapshot(&workspace_root)") + .expect("workspace snapshot"); + let state = production + .find("AppState::new(") + .expect("app state construction"); + let terminal = production + .find("Terminal::new") + .expect("frame loop terminal"); + assert!(snapshot < state); + assert!(state < terminal); + assert!(!production[state..].contains("workspace_status::snapshot(")); + } + + #[test] + fn focus_events_are_consumed_before_normal_input_handlers() { + let production = include_str!("app.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production source before tests"); + let focus = production + .find("consume_focus_event(&ev") + .expect("focus consumption"); + let paste = production + .find("handle_paste_event(&ev") + .expect("paste handling"); + let resize = production + .find("handle_resize_event(&ev") + .expect("resize handling"); + let key = production + .find("let Event::Key(key) = &ev") + .expect("key handling"); + + assert!(focus < paste); + assert!(focus < resize); + assert!(focus < key); + } + + #[test] + fn synthetic_enter_branch_reduces_dynamic_status_flow_before_immediate_return() { + let production = production_app_source(); + let branch_start = production + .find("MouseFlow::SyntheticEnter =>") + .expect("synthetic enter branch"); + let branch = &production[branch_start..production[branch_start..] + .find("\n }\n }") + .map(|offset| branch_start + offset) + .expect("synthetic enter branch end")]; + + assert_status_flow_branch_contract(branch); + assert!(!branch.contains("handle_status_key(")); + } + + #[test] + fn direct_key_branch_reduces_dynamic_status_flow_before_immediate_return() { + let production = production_app_source(); + let branch_start = production + .find("let Event::Key(key) = &ev else") + .expect("direct key branch"); + let branch = &production[branch_start + ..production[branch_start..] + .find("\n }\n },") + .map(|offset| branch_start + offset) + .expect("direct key branch end")]; + + assert_status_flow_branch_contract(branch); + } + + fn assert_status_flow_branch_contract(branch: &str) { + let routed = branch + .find("let status_flow = handle_status_key_dynamic(") + .expect("dynamic status flow"); + let reduced = branch + .find("apply_status_key_flow(") + .expect("single status flow reducer"); + let exit = branch.find("return Ok(Some(code));").expect("exit return"); + let no_exit = branch.rfind("return Ok(None);").expect("non-exit return"); + + assert!(routed < reduced && reduced < exit && exit < no_exit); + assert!(!branch[reduced..].contains("handle_status_key_dynamic(")); + assert!(!branch.contains("scheduler.mark_dirty")); + assert!( + branch[no_exit + "return Ok(None);".len()..] + .trim() + .is_empty() + ); + } + + #[test] + fn production_uses_one_status_flow_reducer_without_route_wrappers() { + let production = production_app_source(); + + assert_eq!(production.matches("fn apply_status_key_flow(").count(), 1); + assert_eq!(production.matches("apply_status_key_flow(").count(), 3); + assert!(!production.contains("apply_direct_status_key_flow")); + assert!(!production.contains("apply_synthetic_enter_status_key_flow")); + } + + #[test] + fn clear_terminal_runs_move_all_purge_then_frame_clear() { + let mut calls = Vec::new(); + + clear_terminal_scrollback_with( + &mut calls, + |calls| { + calls.push("MoveTo"); + Ok(()) + }, + |calls| { + calls.push("All"); + Ok(()) + }, + |calls| { + calls.push("Purge"); + Ok(()) + }, + |calls| { + calls.push("FrameClear"); + Ok(()) + }, + ) + .expect("clear sequence should succeed"); + + assert_eq!(calls, ["MoveTo", "All", "Purge", "FrameClear"]); + } -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::KeyCode; - use orca_core::approval_types::ApprovalMode; - use orca_core::model::ModelSelection; - use tui_textarea::TextArea; + #[test] + fn clear_terminal_preserves_each_stage_error_and_short_circuits() { + let stages = ["MoveTo", "All", "Purge", "FrameClear"]; + let kinds = [ + io::ErrorKind::NotFound, + io::ErrorKind::PermissionDenied, + io::ErrorKind::BrokenPipe, + io::ErrorKind::TimedOut, + ]; + let messages = ["move failed", "all failed", "purge failed", "frame failed"]; + + for failing_stage in 0..stages.len() { + let mut calls = Vec::new(); + let result = clear_terminal_scrollback_with( + &mut calls, + |calls| { + calls.push("MoveTo"); + if failing_stage == 0 { + Err(io::Error::new(kinds[0], messages[0])) + } else { + Ok(()) + } + }, + |calls| { + calls.push("All"); + if failing_stage == 1 { + Err(io::Error::new(kinds[1], messages[1])) + } else { + Ok(()) + } + }, + |calls| { + calls.push("Purge"); + if failing_stage == 2 { + Err(io::Error::new(kinds[2], messages[2])) + } else { + Ok(()) + } + }, + |calls| { + calls.push("FrameClear"); + if failing_stage == 3 { + Err(io::Error::new(kinds[3], messages[3])) + } else { + Ok(()) + } + }, + ); - use crate::approval_actions::resolve_approval_option; - use crate::commands; - use crate::composer_textarea::{ - insert_composer_paste, insert_pasted_text, make_textarea_with_text, textarea_text, - }; - use crate::idle_submit_actions::handle_idle_submit; - use crate::slash_command_actions::handle_slash_command; - use crate::types::{ApprovalOption, PendingTuiInput, SlashMenu, SlashMenuItem, SubMenu}; - use crate::types::{TuiInteractionKey, TuiInteractionKind, TuiInteractionResponse}; - use crate::workflow_notifications::drain_pending_workflow_notifications; - use crate::workflow_notifications::{ - is_workflow_notification_turn_boundary, queue_workflow_terminal_notification, - remove_pending_workflow_notification_by_id, submit_pending_workflow_notification, - }; - use crate::workflow_panel_actions::handle_workflows_panel_key; - use orca_core::config::{ - ModelRuntimeConfig, OutputFormat, ProviderKind, ThemeName, ToolConfig, WorkflowConfig, - }; - use tempfile::tempdir; + let error = result.expect_err("selected clear stage should fail"); + assert_eq!(error.kind(), kinds[failing_stage]); + assert_eq!(error.to_string(), messages[failing_stage]); + assert_eq!(calls, stages[..=failing_stage]); + } + } fn test_config(history_mode: HistoryMode) -> RunConfig { RunConfig { @@ -626,8 +2782,10 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } @@ -645,6 +2803,391 @@ mod tests { ) } + const POLL_EDIT_DIFF: &str = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +"; + + fn state_with_pending_edit() -> (tempfile::TempDir, AppState) { + let directory = tempdir().expect("edit workspace"); + std::fs::create_dir_all(directory.path().join("src")).expect("source directory"); + std::fs::write(directory.path().join("src/item.py"), "value = 2\n") + .expect("post-edit file"); + let (mut state, _rx) = test_state(); + state.configure_syntax_highlighting( + directory.path().to_path_buf(), + crate::syntax_highlight::SyntaxTheme::OneHalfDark, + crate::terminal_capabilities::TerminalColorLevel::TrueColor, + ); + state.update(TuiEvent::ToolRequested { + id: "edit-1".to_string(), + name: "edit".to_string(), + target: Some("src/item.py".to_string()), + }); + state.update(TuiEvent::ToolCompleted { + id: "edit-1".to_string(), + name: "edit".to_string(), + status: "completed".to_string(), + output: "edited src/item.py".to_string(), + diff: Some(POLL_EDIT_DIFF.to_string()), + kind: None, + }); + assert!(state.edit_highlight_needs_tick()); + (directory, state) + } + + fn ready_drain( + runtime: &mut crate::edit_highlight_worker::EditHighlightRuntime, + ) -> crate::edit_highlight_worker::DrainResults { + use ratatui::style::{Color, Style}; + use ratatui::text::Span; + + let job = runtime.pending_job("edit-1").expect("pending edit"); + let styles = crate::diff_highlight::RefinedDiffStyles::from([( + 1, + vec![Span::styled( + "value = 2", + Style::default().fg(Color::Magenta), + )], + )]); + crate::edit_highlight_worker::DrainResults { + results: vec![crate::edit_highlight_worker::EditHighlightResult { + job, + outcome: crate::edit_highlight_worker::EditHighlightOutcome::Ready { + styles: Arc::new(styles), + }, + }], + disconnected: false, + } + } + + fn failed_drain( + runtime: &mut crate::edit_highlight_worker::EditHighlightRuntime, + ) -> crate::edit_highlight_worker::DrainResults { + let job = runtime.pending_job("edit-1").expect("pending edit"); + crate::edit_highlight_worker::DrainResults { + results: vec![crate::edit_highlight_worker::EditHighlightResult { + job, + outcome: crate::edit_highlight_worker::EditHighlightOutcome::Failed, + }], + disconnected: false, + } + } + + fn stale_drain( + runtime: &mut crate::edit_highlight_worker::EditHighlightRuntime, + ) -> crate::edit_highlight_worker::DrainResults { + let mut job = runtime.pending_job("edit-1").expect("pending edit"); + job.message_revision = job.message_revision.saturating_add(1); + crate::edit_highlight_worker::DrainResults { + results: vec![crate::edit_highlight_worker::EditHighlightResult { + job, + outcome: crate::edit_highlight_worker::EditHighlightOutcome::Failed, + }], + disconnected: false, + } + } + + fn disconnected_drain( + _runtime: &mut crate::edit_highlight_worker::EditHighlightRuntime, + ) -> crate::edit_highlight_worker::DrainResults { + crate::edit_highlight_worker::DrainResults { + results: Vec::new(), + disconnected: true, + } + } + + #[test] + fn ready_edit_highlight_poll_marks_dirty_and_clears_pending_animation() { + let (_directory, mut state) = state_with_pending_edit(); + let started = Instant::now(); + let mut scheduler = FrameScheduler::new( + started, + Duration::from_millis(16), + Duration::from_millis(80), + ); + scheduler.did_draw(started); + state.set_edit_highlight_drain_for_test(Some(ready_drain)); + + assert!(edit_highlight_animation_active(&state)); + assert!(poll_edit_highlight(&mut state, &mut scheduler)); + assert!(!edit_highlight_animation_active(&state)); + assert!(!scheduler.should_draw(started + Duration::from_millis(15))); + let draw_at = started + Duration::from_millis(16); + assert!(scheduler.should_draw(draw_at)); + scheduler.did_draw(draw_at); + assert!(!scheduler.should_draw(draw_at + Duration::from_secs(1))); + } + + #[test] + fn idle_ready_poll_schedules_actual_render_with_refined_styles_once() { + let (_directory, mut state) = state_with_pending_edit(); + state.push_message(ChatMessage::System("stable".to_string())); + assert_eq!(state.status, AppStatus::Idle); + let theme = Theme::named(ThemeName::Dark); + let textarea = TextArea::default(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20)) + .expect("test backend"); + + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("cold render"); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 2); + let cold = state + .transcript_render_cache + .viewport(0, usize::MAX, usize::MAX); + let cold_insert = inserted_source_line(&cold.lines, "value = 2"); + assert!( + cold_insert + .spans + .iter() + .all(|span| span.style.fg != Some(ratatui::style::Color::Magenta)) + ); + + let revisions_before = state.message_revisions.clone(); + let started = Instant::now(); + let mut scheduler = FrameScheduler::new( + started, + Duration::from_millis(16), + Duration::from_millis(80), + ); + scheduler.did_draw(started); + state.set_edit_highlight_drain_for_test(Some(ready_drain)); + + assert!(edit_highlight_animation_active(&state)); + assert!(poll_edit_highlight(&mut state, &mut scheduler)); + assert_ne!(state.message_revisions[0], revisions_before[0]); + assert_eq!(state.message_revisions[1], revisions_before[1]); + assert_eq!(state.pending_edit_highlight_count_for_test(), 0); + assert!(!edit_highlight_animation_active(&state)); + + let draw_at = started + Duration::from_millis(16); + assert!(scheduler.should_draw(draw_at)); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("refined render"); + scheduler.did_draw(draw_at); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 1); + let warm = state + .transcript_render_cache + .viewport(0, usize::MAX, usize::MAX); + let warm_insert = inserted_source_line(&warm.lines, "value = 2"); + assert_eq!( + warm_insert + .spans + .iter() + .filter(|span| { span.style.fg == Some(ratatui::style::Color::Magenta) }) + .map(|span| span.content.as_ref()) + .collect::(), + "value = 2" + ); + + let revisions_after = state.message_revisions.clone(); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("steady render"); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 0); + assert_eq!(state.message_revisions, revisions_after); + assert!(!scheduler.should_draw(draw_at + Duration::from_secs(1))); + } + + #[test] + fn failed_and_stale_edit_highlight_polls_do_not_mark_dirty() { + for (drain, remains_pending) in [ + ( + failed_drain + as fn( + &mut crate::edit_highlight_worker::EditHighlightRuntime, + ) -> crate::edit_highlight_worker::DrainResults, + false, + ), + (stale_drain, true), + ] { + let (_directory, mut state) = state_with_pending_edit(); + let started = Instant::now(); + let mut scheduler = FrameScheduler::new( + started, + Duration::from_millis(16), + Duration::from_millis(80), + ); + scheduler.did_draw(started); + state.set_edit_highlight_drain_for_test(Some(drain)); + + assert!(!poll_edit_highlight(&mut state, &mut scheduler)); + assert_eq!(edit_highlight_animation_active(&state), remains_pending); + assert!(!scheduler.should_draw(started + Duration::from_millis(16))); + } + } + + #[test] + fn disconnected_edit_highlight_poll_drops_runtime_and_stops_pending_tick() { + let (_directory, mut state) = state_with_pending_edit(); + let started = Instant::now(); + let mut scheduler = FrameScheduler::new( + started, + Duration::from_millis(16), + Duration::from_millis(80), + ); + scheduler.did_draw(started); + state.set_edit_highlight_drain_for_test(Some(disconnected_drain)); + + assert!(edit_highlight_animation_active(&state)); + assert!(!poll_edit_highlight(&mut state, &mut scheduler)); + assert!(!state.edit_highlight_runtime_started_for_test()); + assert!(!edit_highlight_animation_active(&state)); + assert!(!scheduler.should_draw(started + Duration::from_millis(16))); + } + + #[test] + fn syntax_workspace_root_preserves_real_configured_path() { + let directory = tempdir().expect("syntax workspace"); + let mut config = test_config(HistoryMode::Disabled); + config.cwd = Some(directory.path().to_path_buf()); + + assert_eq!( + syntax_workspace_root(&config), + directory.path().to_path_buf() + ); + } + + #[test] + fn mention_search_roots_reuse_captured_workspace_fallback() { + let directory = tempdir().expect("captured mention workspace"); + let mut config = test_config(HistoryMode::Disabled); + config.cwd = None; + + assert_eq!( + mention_search_roots(&config, directory.path()), + vec![directory.path().to_path_buf()] + ); + } + + #[test] + fn startup_configures_exact_workspace_before_replay_without_starting_runtime() { + let directory = tempdir().expect("startup syntax workspace"); + let theme = Theme::named(ThemeName::Light); + let (mut state, _rx) = test_state(); + let historical = ChatMessage::ToolCall { + id: "historical-edit".to_string(), + name: "edit".to_string(), + target: Some("src/item.py".to_string()), + status: "completed".to_string(), + output: None, + diff: Some( + "--- a/src/item.py\n+++ b/src/item.py\n@@ -1 +1 @@\n-old\n+new\n".to_string(), + ), + kind: None, + expanded: false, + }; + + configure_and_preload_tui_state( + &mut state, + directory.path().to_path_buf(), + theme.syntax_theme, + theme.color_level, + [historical], + ); + + assert_eq!( + state.syntax_workspace_root_for_test(), + Some(directory.path()) + ); + assert_eq!( + state.syntax_theme_for_test(), + crate::syntax_highlight::SyntaxTheme::OneHalfLight + ); + assert_eq!( + state.syntax_color_level_for_test(), + crate::terminal_capabilities::TerminalColorLevel::TrueColor + ); + assert_eq!(state.messages.len(), 1); + assert!(!state.edit_highlight_runtime_started_for_test()); + assert_eq!(state.pending_edit_highlight_count_for_test(), 0); + } + + #[test] + fn startup_configuration_reuses_captured_workspace_after_cwd_changes() { + struct CurrentDirGuard(PathBuf); + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.0).expect("restore current directory"); + } + } + + let _lock = crate::test_support::lock_process_env(); + let original = std::env::current_dir().expect("original current directory"); + let workspace_a = tempdir().expect("workspace A"); + let workspace_b = tempdir().expect("workspace B"); + let _restore = CurrentDirGuard(original); + std::env::set_current_dir(workspace_a.path()).expect("set workspace A"); + let mut config = test_config(HistoryMode::Disabled); + config.cwd = None; + let captured_workspace = syntax_workspace_root(&config); + std::env::set_current_dir(workspace_b.path()).expect("set workspace B"); + let theme = Theme::named(ThemeName::Catppuccin); + let (mut state, _rx) = test_state(); + + configure_tui_syntax_state( + &mut state, + captured_workspace.clone(), + theme.syntax_theme, + theme.color_level, + ); + + assert_eq!( + state.syntax_workspace_root_for_test(), + Some(captured_workspace.as_path()) + ); + assert_eq!( + state.syntax_theme_for_test(), + crate::syntax_highlight::SyntaxTheme::CatppuccinMocha + ); + } + + #[test] + fn syntax_workspace_root_uses_current_dir_when_config_cwd_is_none() { + struct CurrentDirGuard(PathBuf); + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.0).expect("restore current directory"); + } + } + + let _lock = crate::test_support::lock_process_env(); + let original = std::env::current_dir().expect("original current directory"); + let directory = tempdir().expect("fallback syntax workspace"); + let _restore = CurrentDirGuard(original); + std::env::set_current_dir(directory.path()).expect("set fallback current directory"); + let mut config = test_config(HistoryMode::Disabled); + config.cwd = None; + let theme = Theme::named(ThemeName::Dark); + let (mut state, _rx) = test_state(); + let expected_workspace = syntax_workspace_root(&config); + + assert_eq!( + expected_workspace, + directory + .path() + .canonicalize() + .expect("canonical fallback workspace") + ); + configure_tui_syntax_state( + &mut state, + expected_workspace.clone(), + theme.syntax_theme, + theme.color_level, + ); + assert_eq!( + state.syntax_workspace_root_for_test(), + Some(expected_workspace.as_path()) + ); + } + fn test_pending_workflow_notifications() -> bridge::PendingWorkflowNotifications { bridge::PendingWorkflowNotifications::new() } @@ -743,13 +3286,16 @@ mod tests { send_submission_error( &event_tx, + None, Some("review @gone.txt"), "bound file is no longer available".to_string(), ); assert!(matches!( event_rx.try_recv(), - Ok(TuiEvent::SubmissionRejected { prompt, message }) + Ok(TuiEvent::SubmissionRejected { + prompt, message, .. + }) if prompt == "review @gone.txt" && message == "bound file is no longer available" )); @@ -791,7 +3337,9 @@ mod tests { harness.recv_until(|event| matches!(event, TuiEvent::SubmissionRejected { .. })); assert!(matches!( rejection, - TuiEvent::SubmissionRejected { prompt, message } + TuiEvent::SubmissionRejected { + prompt, message, .. + } if prompt == "review @gone.txt" && message.contains("failed to resolve bound @gone.txt") )); @@ -799,11 +3347,59 @@ mod tests { }); } + #[test] + fn queued_stale_bound_file_rejection_preserves_queued_identity() { + with_orca_home(|_| { + let root = tempdir().expect("workspace root"); + let root_path = root + .path() + .canonicalize() + .expect("canonical workspace root"); + let mut config = test_config(HistoryMode::Disabled); + config.cwd = Some(root_path.clone()); + config.runtime_workspace_roots = Some(vec![root_path.clone()]); + let prompt = "review @gone.txt"; + let bindings = orca_runtime::mentions::MentionBindings::from_bindings( + prompt, + vec![orca_runtime::mentions::MentionBinding { + start: 7, + end: prompt.len(), + visible: "@gone.txt".to_string(), + target: orca_runtime::mentions::MentionTarget::File { + root: root_path, + path: "gone.txt".to_string(), + kind: orca_runtime::mentions::MentionFileKind::File, + }, + }], + ); + let mut harness = HostedTuiHarness::start(config, None); + + harness.send(UserAction::SubmitQueued { + id: 42, + prompt: prompt.to_string(), + bindings, + }); + + let rejection = + harness.recv_until(|event| matches!(event, TuiEvent::SubmissionRejected { .. })); + assert!(matches!( + rejection, + TuiEvent::SubmissionRejected { + queued_id: Some(42), + prompt, + message, + } if prompt == "review @gone.txt" + && message.contains("failed to resolve bound @gone.txt") + )); + harness.shutdown(); + }); + } + #[test] fn workflow_submission_error_remains_generic() { let (event_tx, event_rx) = mpsc::unbounded(); - send_submission_error(&event_tx, None, "workflow failed".to_string()); + send_submission_error(&event_tx, None, None, "workflow failed".to_string()); assert!(matches!( event_rx.try_recv(), @@ -818,6 +3414,7 @@ mod tests { let shared_config = Arc::new(Mutex::new(config.clone())); let (action_tx, _action_rx) = mpsc::unbounded(); let operation = crate::test_support::TestOperationInterrupt::default(); + let mut vim = VimState::new(false); let pos = crate::selection::SelectionPos { row: 0, col: 0 }; let head = crate::selection::SelectionPos { row: 2, col: 5 }; @@ -836,6 +3433,7 @@ mod tests { &shared_config, &action_tx, &operation, + &mut vim, || Ok(()), ) .expect("preflight"); @@ -851,6 +3449,7 @@ mod tests { &shared_config, &action_tx, &operation, + &mut vim, || Ok(()), ) .expect("preflight"); @@ -1880,7 +4479,9 @@ mod tests { assert!(matches!( event_rx.recv_timeout(Duration::from_secs(1)), - Ok(TuiEvent::SubmissionRejected { prompt, message }) + Ok(TuiEvent::SubmissionRejected { + prompt, message, .. + }) if prompt == "retry me" && message.contains("failed to initialize conversation history") )); @@ -1912,12 +4513,96 @@ mod tests { cfg, &controller, &event_tx, + None, + ); + + assert!(result.is_err()); + assert!(matches!( + event_rx.recv_timeout(Duration::from_secs(1)), + Ok(TuiEvent::SessionCompleted { status }) if status == "failed" + )); + host.shutdown().unwrap(); + }); + } + + #[test] + fn queued_operation_controller_install_failure_preserves_queued_identity() { + with_orca_home(|_| { + let cfg = test_config(HistoryMode::Record); + let host = orca_runtime::runtime_host::RuntimeHost::start().unwrap(); + let runtime_thread = host + .start_thread(cfg.clone(), "queued install failure") + .unwrap(); + let controller = TuiOperationController::hosted(TuiInteractionBroker::default()); + controller.shutdown(); + let (event_tx, event_rx) = mpsc::unbounded(); + + let result = run_hosted_operation( + &runtime_thread, + HostedTurnRequest::new("restore queued prompt"), + cfg, + &controller, + &event_tx, + Some(42), + ); + + assert!(result.is_err()); + let events = event_rx.try_iter().collect::>(); + let acknowledged = events + .iter() + .any(|event| matches!(event, TuiEvent::QueuedSubmissionStarted { id: 42 })); + let rejected = events.iter().any(|event| { + matches!( + event, + TuiEvent::SubmissionRejected { + queued_id: Some(42), + prompt, + message, + } if prompt == "restore queued prompt" + && message.contains("controller is shutting down") + ) + }); + assert_ne!( + acknowledged, rejected, + "queued identity must be acknowledged or rejected exactly once: {events:?}" + ); + host.shutdown().unwrap(); + }); + } + + #[test] + fn queued_goal_preflight_failure_preserves_queued_identity() { + with_orca_home(|_| { + let cfg = test_config(HistoryMode::Disabled); + let host = orca_runtime::runtime_host::RuntimeHost::start().unwrap(); + let runtime_thread = host + .start_thread(cfg.clone(), "queued preflight failure") + .unwrap(); + let controller = TuiOperationController::hosted(TuiInteractionBroker::default()); + let (event_tx, event_rx) = mpsc::unbounded(); + + run_hosted_goal_run( + &cfg, + &runtime_thread, + SubmittedTurn::queued_user_with_mentions( + 42, + "restore queued prompt".to_string(), + orca_runtime::mentions::MentionBindings::new("restore queued prompt"), + ), + orca_core::goal_runtime::GoalTurnOrigin::User, + &event_tx, + &controller, + Some(42), ); - assert!(result.is_err()); assert!(matches!( event_rx.recv_timeout(Duration::from_secs(1)), - Ok(TuiEvent::SessionCompleted { status }) if status == "failed" + Ok(TuiEvent::SubmissionRejected { + queued_id: Some(42), + prompt, + message, + }) if prompt == "restore queued prompt" + && message.contains("persistent goals require recorded history") )); host.shutdown().unwrap(); }); @@ -3809,6 +6494,440 @@ mod tests { assert!(rendered.contains("answer")); } + #[test] + fn running_queue_preview_restore_and_terminal_dispatch_frames_are_consistent() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + let mut config = test_config(HistoryMode::Record); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = crate::test_support::TestOperationInterrupt::default(); + let preloaded = Arc::new(Mutex::new(None)); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = TextArea::default(); + + for code in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] { + let key = KeyEvent::new(code, KeyModifiers::NONE); + handle_status_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + } + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + handle_status_key( + &Event::Key(enter), + &enter, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + assert_eq!(state.queued_user_messages.len(), 1); + assert!(action_rx.try_recv().is_err()); + assert!( + !state + .messages + .iter() + .any(|message| matches!(message, ChatMessage::User(text) if text == "foo")) + ); + + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 10)).unwrap(); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .unwrap(); + assert!(format!("{:?}", terminal.backend().buffer()).contains("Queued 1")); + + let restore = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT); + handle_status_key( + &Event::Key(restore), + &restore, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + assert!(state.queued_user_messages.is_empty()); + assert_eq!(textarea_text(&textarea), "foo"); + + textarea.insert_char('!'); + handle_status_key( + &Event::Key(enter), + &enter, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + assert!(action_rx.try_recv().is_err()); + + let pending = test_pending_workflow_notifications(); + let mut presentation = TerminalPresentation::new( + false, + TerminalPresentationProfile { + osc9_supported: false, + tmux_passthrough: false, + }, + ); + handle_runtime_event( + TuiEvent::SessionCompleted { + status: "success".to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitQueued { prompt, .. }) if prompt == "foo!" + )); + assert!(matches!( + state.messages.last(), + Some(ChatMessage::User(text)) if text == "foo!" + )); + assert!(state.queued_user_messages.is_empty()); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .unwrap(); + assert!(!format!("{:?}", terminal.backend().buffer()).contains("Queued 1")); + } + + #[test] + fn hosted_tui_runs_app_state_queued_follow_ups_one_at_a_time_in_fifo_order() { + with_orca_home(|_| { + let mut harness = HostedTuiHarness::start(test_config(HistoryMode::Record), None); + harness.send(UserAction::Submit("mock_stream_delay_ms 100".to_string())); + harness.recv_until(|event| matches!(event, TuiEvent::TurnStarted { .. })); + let first_terminal = harness.recv_until(|event| { + matches!(event, TuiEvent::SessionCompleted { status } if status == "success") + }); + + let mut state = AppState::new( + harness.action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + for _ in 0..2 { + state + .enqueue_user_message( + crate::queued_input::QueuedUserMessage::from_composer( + "mock_history_echo".to_string(), + Vec::new(), + orca_runtime::mentions::MentionBindings::default(), + ) + .unwrap(), + ) + .unwrap(); + } + state.enter_running(); + let pending = test_pending_workflow_notifications(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let mut presentation = TerminalPresentation::new( + false, + TerminalPresentationProfile { + osc9_supported: false, + tmux_passthrough: false, + }, + ); + + let mut terminal_event = first_terminal; + for expected_count in [2usize, 3usize] { + handle_runtime_event( + terminal_event, + &mut state, + &harness.action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + let queued_started = harness + .recv_until(|event| matches!(event, TuiEvent::QueuedSubmissionStarted { .. })); + state.update(queued_started); + let turn_started = + harness.recv_until(|event| matches!(event, TuiEvent::TurnStarted { .. })); + state.update(turn_started); + let delta = harness.recv_until(|event| { + matches!( + event, + TuiEvent::MessageDelta(text) + if text.contains("Mock history users:") + ) + }); + let TuiEvent::MessageDelta(text) = delta else { + unreachable!() + }; + let expected = format!( + "Mock history users: {}", + std::iter::once("mock_stream_delay_ms 100") + .chain(std::iter::repeat_n("mock_history_echo", expected_count - 1,)) + .collect::>() + .join(" | ") + ); + assert_eq!(text, expected); + terminal_event = harness.recv_until(|event| { + matches!( + event, + TuiEvent::SessionCompleted { status } + if status == "success" + ) + }); + if expected_count == 2 { + assert_eq!(state.queued_user_messages.len(), 1); + state.set_status(AppStatus::Running); + } + } + + assert!(state.queued_user_messages.is_empty()); + harness.shutdown(); + }); + } + + #[test] + fn search_keyboard_frames_move_active_match_without_composer_mutation() { + let (mut state, _rx) = test_state(); + for index in 0..30 { + state.push_message(ChatMessage::System(format!("row {index:02} alpha"))); + } + state.auto_scroll = false; + let theme = Theme::named(ThemeName::Dark); + let textarea = TextArea::from(["composer draft"]); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 10)) + .expect("test backend"); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("initial draw"); + + state.open_transcript_search(); + state.replace_transcript_search_query("alpha"); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("search draw"); + let first = state.transcript_search.active_ordinal(); + assert!(format!("{:?}", terminal.backend().buffer()).contains("1/30")); + + handle_transcript_search_key( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + ); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("next draw"); + assert_ne!(state.transcript_search.active_ordinal(), first); + assert!(!state.auto_scroll); + assert_eq!(textarea.lines(), &["composer draft".to_string()]); + + handle_transcript_search_key( + KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), + &mut state, + ); + assert_eq!(state.transcript_search.active_ordinal(), first); + } + + #[test] + fn running_search_esc_closes_before_interrupt_and_paste_never_touches_composer() { + let (mut state, _state_action_rx) = test_state(); + state.enter_running(); + state.open_transcript_search(); + let mut textarea = TextArea::from(["composer"]); + let operation = crate::test_support::TestOperationInterrupt::default(); + let mut config = test_config(HistoryMode::Record); + let shared = Arc::new(Mutex::new(config.clone())); + let (action_tx, action_rx) = mpsc::unbounded(); + let mut vim = VimState::new(false); + + assert!(handle_paste_event( + &Event::Paste("alpha\r\nbeta".to_string()), + &mut state, + &config, + &mut textarea, + )); + assert_eq!(state.transcript_search.query(), "alpha beta"); + assert_eq!(textarea.lines(), &["composer".to_string()]); + assert!(state.pending_pastes.is_empty()); + + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + handle_key_event_preflight( + esc, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(); + assert!(!state.transcript_search.open); + assert_eq!(operation.call_count(), 0); + + let preloaded = Arc::new(Mutex::new(None)); + let theme = Theme::named(ThemeName::Dark); + handle_status_key( + &Event::Key(esc), + &esc, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + assert_eq!(operation.call_count(), 1); + assert!(matches!(action_rx.try_recv(), Ok(UserAction::Interrupt))); + } + + #[test] + fn mouse_selection_over_search_match_wins_and_copy_stays_exact() { + let (mut state, _rx) = test_state(); + state.push_message(ChatMessage::System("alpha beta".to_string())); + state.open_transcript_search(); + state.replace_transcript_search_query("alpha"); + let theme = Theme::named(ThemeName::Dark); + let textarea = TextArea::default(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 8)) + .expect("test backend"); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("search draw"); + + state.selection = Some(TranscriptSelection::unit( + SelectionGranularity::Cell, + SelectionPos { row: 0, col: 1 }, + SelectionPos { row: 0, col: 3 }, + )); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("selection draw"); + assert_eq!( + state + .transcript_render_cache + .extract_text(state.selection.as_ref().unwrap()), + "lph" + ); + let selected_cells = terminal + .backend() + .buffer() + .content() + .iter() + .filter(|cell| cell.style().bg == theme.selection_style().bg) + .count(); + assert!(selected_cells >= 3); + } + + #[test] + fn streaming_and_resize_refresh_matches_without_stealing_active_identity() { + let (mut state, _rx) = test_state(); + state.update(TuiEvent::MessageDelta( + "prefix long words before alpha\n\nhidden alpha".to_string(), + )); + state.open_transcript_search(); + state.replace_transcript_search_query("alpha"); + let theme = Theme::named(ThemeName::Dark); + let textarea = TextArea::default(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(20, 8)) + .expect("test backend"); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("held draw"); + assert_eq!(state.transcript_search.match_count(), 1); + let identity = state + .transcript_search + .active_match() + .unwrap() + .line_identity; + + state.update(TuiEvent::MessageDelta("\n".to_string())); + terminal + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("released draw"); + assert_eq!(state.transcript_search.match_count(), 2); + assert_eq!( + state + .transcript_search + .active_match() + .unwrap() + .line_identity, + identity + ); + let before = state.transcript_search.active_match().unwrap().start; + + let mut resized = ratatui::Terminal::new(ratatui::backend::TestBackend::new(8, 8)) + .expect("resized backend"); + resized + .draw(|frame| ui::render(frame, &mut state, &textarea, &theme)) + .expect("resized draw"); + assert_eq!( + state + .transcript_search + .active_match() + .unwrap() + .line_identity, + identity + ); + assert_ne!( + state.transcript_search.active_match().unwrap().start, + before + ); + } + #[test] fn slash_menu_tab_opens_history_picker_like_enter() { with_orca_home(|home| { @@ -3822,6 +6941,26 @@ mod tests { let (mut state, _rx) = test_state(); state.status = AppStatus::Idle; + state + .enqueue_user_message( + crate::queued_input::QueuedUserMessage::from_composer( + "queued".to_string(), + Vec::new(), + orca_runtime::mentions::MentionBindings::default(), + ) + .unwrap(), + ) + .unwrap(); + state.queued_submission_in_flight = Some( + crate::queued_input::QueuedUserMessage::from_composer( + "in flight".to_string(), + Vec::new(), + orca_runtime::mentions::MentionBindings::default(), + ) + .unwrap(), + ); + state.queued_input_error = Some("error".to_string()); + state.suspend_queued_follow_up_autosend(); state.slash_menu = Some(SlashMenu { items: commands::all_commands() .iter() @@ -3866,6 +7005,10 @@ mod tests { assert_eq!(state.status, AppStatus::SessionPicker); assert!(!state.session_picker_sessions.is_empty()); assert!(state.slash_menu.is_none()); + assert!(state.queued_user_messages.is_empty()); + assert!(state.queued_submission_in_flight.is_none()); + assert!(state.queued_input_error.is_none()); + assert!(state.queued_follow_up_autosend); }); } @@ -4439,11 +7582,13 @@ fn goal_continuation_prompt(objective: &str, continuation: usize) -> String { fn send_submission_error( event_tx: &mpsc::Sender, + queued_id: Option, rejection_prompt: Option<&str>, message: String, ) { if let Some(prompt) = rejection_prompt { let _ = event_tx.send(TuiEvent::SubmissionRejected { + queued_id, prompt: prompt.to_string(), message, }); @@ -4465,13 +7610,10 @@ fn hosted_tui_controller_loop( ) { let mut thread: Option = None; let mut pending_pinned_context = Vec::new(); - let mut pending_actions = VecDeque::new(); loop { let action = if controller.is_shutdown() { Ok(UserAction::Cancel) - } else if let Some(action) = pending_actions.pop_front() { - Ok(action) } else { action_rx.recv() }; @@ -4502,6 +7644,24 @@ fn hosted_tui_controller_loop( &host, ); } + Ok(UserAction::SubmitQueued { + id, + prompt, + bindings, + }) => { + handle_hosted_submitted_turn( + SubmittedTurn::queued_user_with_mentions(id, prompt, bindings), + &config, + &preloaded, + &mut thread, + &mut pending_pinned_context, + &event_tx, + &controller, + &pending_workflow_notifications, + &mcp_registry, + &host, + ); + } Ok(UserAction::SubmitWorkflowNotification(notification)) => { handle_hosted_submitted_turn( SubmittedTurn::workflow_notification(notification), @@ -4585,7 +7745,7 @@ fn hosted_tui_controller_loop( .with_operation_kind(HostedOperationKind::ManualCompaction); let cfg = config.lock().unwrap().clone(); if let Err(error) = - run_hosted_operation(runtime_thread, request, cfg, &controller, &event_tx) + run_hosted_operation(runtime_thread, request, cfg, &controller, &event_tx, None) { let _ = event_tx.send(TuiEvent::Error(format!( "manual compaction failed: {error}" @@ -4635,18 +7795,15 @@ fn hosted_tui_controller_loop( task_id: continuation.task_id().to_string(), }) .with_goal_usage_tracking(true); - match run_hosted_operation(runtime_thread, request, cfg, &controller, &event_tx) - { - Ok(TuiHostedOperationOutcome::Turn { status }) => { - if status == "success" - && let Some(notification) = - pending_workflow_notifications.pop_notification() - { - pending_actions.push_front(UserAction::SubmitWorkflowNotification( - notification, - )); - } - } + match run_hosted_operation( + runtime_thread, + request, + cfg, + &controller, + &event_tx, + None, + ) { + Ok(TuiHostedOperationOutcome::Turn { .. }) => {} Ok(TuiHostedOperationOutcome::ManualCompaction) => {} Err(error) => { let _ = event_tx.send(TuiEvent::Error(error.to_string())); @@ -4728,6 +7885,7 @@ fn hosted_tui_controller_loop( orca_core::goal_runtime::GoalTurnOrigin::User, &event_tx, &controller, + None, ); } } @@ -4866,6 +8024,7 @@ fn hosted_tui_controller_loop( orca_core::goal_runtime::GoalTurnOrigin::Resume, &event_tx, &controller, + None, ); } } @@ -4931,6 +8090,7 @@ fn handle_hosted_submitted_turn( host: &RuntimeHostHandle, ) { let rejection_prompt = submitted_turn.rejection_prompt().map(str::to_string); + let queued_id = submitted_turn.queued_id(); let cfg = config.lock().unwrap().clone(); let cwd = cfg .cwd @@ -4947,7 +8107,7 @@ fn handle_hosted_submitted_turn( pending_pinned_context, event_tx, ) { - send_submission_error(event_tx, rejection_prompt.as_deref(), error); + send_submission_error(event_tx, queued_id, rejection_prompt.as_deref(), error); return; } let runtime_thread = thread.as_ref().expect("hosted thread initialized"); @@ -4963,7 +8123,7 @@ fn handle_hosted_submitted_turn( ) { Ok(prompt) => prompt, Err(error) => { - send_submission_error(event_tx, rejection_prompt.as_deref(), error); + send_submission_error(event_tx, queued_id, rejection_prompt.as_deref(), error); return; } }; @@ -4974,6 +8134,7 @@ fn handle_hosted_submitted_turn( orca_core::goal_runtime::GoalTurnOrigin::User, event_tx, controller, + queued_id, ); if cfg.desktop_notifications { let _ = orca_runtime::notify::notify("Orca", "Task completed"); @@ -4986,9 +8147,13 @@ fn run_hosted_operation( config: RunConfig, controller: &TuiOperationController, event_tx: &mpsc::Sender, + queued_id: Option, ) -> io::Result { let operation_kind = request.operation_kind().clone(); - let observer = Arc::new(TuiHostedEventObserver::new(event_tx.clone())); + let observer = Arc::new(TuiHostedEventObserver::new_with_queued_id( + event_tx.clone(), + queued_id, + )); let pending_interactions = orca_runtime::runtime_pending_interaction::RuntimePendingInteractionStore::default(); let generation_pending_interactions = pending_interactions.clone(); @@ -5022,10 +8187,19 @@ fn run_hosted_operation( .with_pending_interactions(generation_pending_interactions.clone()), )) }); + let rejection_prompt = queued_id.map(|_| request.prompt().to_string()); let operation = match thread.start_turn_with_config(request, io::sink(), config) { Ok(operation) => Arc::new(operation), Err(error) => { - send_hosted_operation_terminal_failure(event_tx, &operation_kind); + if let Some(id) = queued_id { + let _ = event_tx.send(TuiEvent::SubmissionRejected { + queued_id: Some(id), + prompt: rejection_prompt.unwrap_or_default(), + message: error.to_string(), + }); + } else { + send_hosted_operation_terminal_failure(event_tx, &operation_kind); + } return Err(io::Error::other(error.to_string())); } }; @@ -5034,8 +8208,19 @@ fn run_hosted_operation( let _ = operation.interrupt(); let _ = operation.wait(); controller.complete_hosted(operation_id); - let _ = observer.finish_foreground(); - send_hosted_operation_terminal_failure(event_tx, &operation_kind); + if let Some(id) = queued_id + && !observer.queued_submission_started() + { + let _ = event_tx.send(TuiEvent::SubmissionRejected { + queued_id: Some(id), + prompt: rejection_prompt.unwrap_or_default(), + message: error.to_string(), + }); + } + let terminal_published = observer.finish_foreground().unwrap_or(false); + if queued_id.is_none() && !terminal_published { + send_hosted_operation_terminal_failure(event_tx, &operation_kind); + } return Err(error); } let terminal = operation.wait(); @@ -5086,22 +8271,51 @@ fn run_hosted_goal_run( origin: orca_core::goal_runtime::GoalTurnOrigin, event_tx: &mpsc::Sender, controller: &TuiOperationController, + queued_id: Option, ) { + let rejection_prompt = submitted_turn.rejection_prompt().map(str::to_string); let Some(session_id) = thread.session_id().map(str::to_string) else { - send_goal_history_error(event_tx); + if queued_id.is_some() { + send_submission_error( + event_tx, + queued_id, + rejection_prompt.as_deref(), + goal_history_error_message().to_string(), + ); + } else { + send_goal_history_error(event_tx); + } return; }; let runtime = match thread.goal_runtime() { Ok(runtime) => runtime, Err(error) => { - let _ = event_tx.send(TuiEvent::Error(error.to_string())); + if queued_id.is_some() { + send_submission_error( + event_tx, + queued_id, + rejection_prompt.as_deref(), + error.to_string(), + ); + } else { + let _ = event_tx.send(TuiEvent::Error(error.to_string())); + } return; } }; let active_goal = match runtime.project_thread_goal(&session_id) { Ok(goal) => goal.filter(|goal| goal.status.should_continue()), Err(error) => { - let _ = event_tx.send(TuiEvent::Error(error.to_string())); + if queued_id.is_some() { + send_submission_error( + event_tx, + queued_id, + rejection_prompt.as_deref(), + error.to_string(), + ); + } else { + let _ = event_tx.send(TuiEvent::Error(error.to_string())); + } return; } }; @@ -5116,7 +8330,14 @@ fn run_hosted_goal_run( } else { request }; - let status = match run_hosted_operation(thread, request, config.clone(), controller, event_tx) { + let status = match run_hosted_operation( + thread, + request, + config.clone(), + controller, + event_tx, + queued_id, + ) { Ok(TuiHostedOperationOutcome::Turn { status }) => status, Ok(TuiHostedOperationOutcome::ManualCompaction) => { let _ = event_tx.send(TuiEvent::Error( @@ -5257,9 +8478,11 @@ fn show_hosted_goal( } fn send_goal_history_error(event_tx: &mpsc::Sender) { - let _ = event_tx.send(TuiEvent::Error( - "persistent goals require recorded history; enable history before using /goal".to_string(), - )); + let _ = event_tx.send(TuiEvent::Error(goal_history_error_message().to_string())); +} + +fn goal_history_error_message() -> &'static str { + "persistent goals require recorded history; enable history before using /goal" } #[allow(clippy::too_many_arguments)] @@ -5377,6 +8600,7 @@ fn resume_latest_active_goal_hosted( orca_core::goal_runtime::GoalTurnOrigin::Resume, event_tx, controller, + None, ); } } diff --git a/crates/orca-tui/src/approval_dialog_actions.rs b/crates/orca-tui/src/approval_dialog_actions.rs index e53f5fbb..d9a068eb 100644 --- a/crates/orca-tui/src/approval_dialog_actions.rs +++ b/crates/orca-tui/src/approval_dialog_actions.rs @@ -3,6 +3,7 @@ use crossbeam_channel as mpsc; use crossterm::event::{KeyCode, KeyEvent}; use crate::approval_actions::resolve_approval_option; +use crate::keybindings::{InputOwnerFingerprint, KeymapRuntime, ShortcutResolution}; use crate::shortcuts::{ApprovalShortcut, ShortcutAction, ShortcutContext, resolve_shortcut}; use crate::types::{AppState, ApprovalOption, UserAction}; @@ -21,25 +22,71 @@ pub(crate) fn handle_approval_dialog_key( return; } - match resolve_shortcut(ShortcutContext::Approval, *key) { - Some(ShortcutAction::Approval(ApprovalShortcut::SelectAllow)) => { + if let Some(ShortcutAction::Approval(shortcut)) = + resolve_shortcut(ShortcutContext::Approval, *key) + { + handle_approval_shortcut(shortcut, state, action_tx); + } +} + +pub(crate) fn handle_approval_dialog_key_dynamic( + key: &KeyEvent, + now: std::time::Instant, + owner: InputOwnerFingerprint, + keymap: &mut KeymapRuntime, + state: &mut AppState, + action_tx: &mpsc::Sender, +) -> bool { + if let KeyCode::Char(character) = key.code + && let Some(option) = state + .approval_dialog + .as_ref() + .and_then(|dialog| dialog.option_for_key(character)) + { + resolve_approval_option(state, action_tx, option); + return true; + } + if key.code == KeyCode::Char('d') { + resolve_approval_option(state, action_tx, ApprovalOption::Deny); + return true; + } + match keymap.resolve_new_context(owner, *key, now) { + ShortcutResolution::Action(invocation) => { + let ShortcutAction::Approval(shortcut) = invocation.action else { + return false; + }; + handle_approval_shortcut(shortcut, state, action_tx); + true + } + ShortcutResolution::Pending => true, + ShortcutResolution::RetryCurrentKey | ShortcutResolution::NoMatch => false, + } +} + +pub(crate) fn handle_approval_shortcut( + shortcut: ApprovalShortcut, + state: &mut AppState, + action_tx: &mpsc::Sender, +) { + match shortcut { + ApprovalShortcut::SelectAllow => { if let Some(dialog) = &mut state.approval_dialog { dialog.selected = dialog.selected.saturating_sub(1); } } - Some(ShortcutAction::Approval(ApprovalShortcut::SelectDeny)) => { + ApprovalShortcut::SelectDeny => { if let Some(dialog) = &mut state.approval_dialog { let last = dialog.options.len().saturating_sub(1); dialog.selected = (dialog.selected + 1).min(last); } } - Some(ShortcutAction::Approval(ApprovalShortcut::ToggleSelection)) => { + ApprovalShortcut::ToggleSelection => { if let Some(dialog) = &mut state.approval_dialog { let len = dialog.options.len().max(1); dialog.selected = (dialog.selected + 1) % len; } } - Some(ShortcutAction::Approval(ApprovalShortcut::Confirm)) => { + ApprovalShortcut::Confirm => { let option = state .approval_dialog .as_ref() @@ -48,12 +95,153 @@ pub(crate) fn handle_approval_dialog_key( resolve_approval_option(state, action_tx, option); } } - Some(ShortcutAction::Approval(ApprovalShortcut::Approve)) => { + ApprovalShortcut::Approve => { resolve_approval_option(state, action_tx, ApprovalOption::Once); } - Some(ShortcutAction::Approval(ApprovalShortcut::Deny)) => { + ApprovalShortcut::Deny => { resolve_approval_option(state, action_tx, ApprovalOption::Deny); } - Some(_) | None => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ApprovalDialog, ApprovalOption}; + + fn state() -> AppState { + let (tx, _rx) = mpsc::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.approval_dialog = Some(ApprovalDialog { + id: "approval".to_string(), + interaction: None, + tool: "shell".to_string(), + target: None, + permission_kind: None, + background_task_id: Some("task".to_string()), + selected: 0, + options: vec![ + ApprovalOption::Once, + ApprovalOption::AlwaysTool, + ApprovalOption::Deny, + ], + diff: None, + }); + state + } + + #[test] + fn action_only_approval_moves_and_confirms_without_synthetic_key() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = state(); + + handle_approval_shortcut(ApprovalShortcut::SelectDeny, &mut state, &action_tx); + assert_eq!(state.approval_dialog.as_ref().unwrap().selected, 1); + handle_approval_shortcut(ApprovalShortcut::Confirm, &mut state, &action_tx); + + assert!(state.approval_dialog.is_none()); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::ResolveBackgroundApproval { id, approved }) + if id == "approval" && approved + )); + assert!( + state + .approval_allowlist + .contains(&AppState::approval_key_tool("shell")) + ); + } + + #[test] + fn fixed_a_key_keeps_always_tool_meaning() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = state(); + let key = KeyEvent::new(KeyCode::Char('a'), crossterm::event::KeyModifiers::NONE); + + handle_approval_dialog_key(&key, &mut state, &action_tx); + + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::ResolveBackgroundApproval { id, approved }) + if id == "approval" && approved + )); + assert!( + state + .approval_allowlist + .contains(&AppState::approval_key_tool("shell")) + ); + } + + #[test] + fn fixed_d_key_keeps_deny_meaning() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = state(); + let key = KeyEvent::new(KeyCode::Char('d'), crossterm::event::KeyModifiers::NONE); + let mut runtime = + crate::keybindings::KeymapRuntime::new(crate::keybindings::Keymap::built_in()); + let owner = crate::keybindings::InputOwnerFingerprint { + context: ShortcutContext::Approval, + modal: crate::keybindings::ModalOwner::Approval, + panel: crate::types::PanelMode::Conversation, + vim_mode: None, + }; + + assert!(handle_approval_dialog_key_dynamic( + &key, + std::time::Instant::now(), + owner, + &mut runtime, + &mut state, + &action_tx, + )); + + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::ResolveBackgroundApproval { id, approved }) + if id == "approval" && !approved + )); + } + + #[test] + fn dynamic_approval_chord_moves_without_synthetic_key() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = state(); + let keymap = crate::keybindings::parse_keymap( + br#"{"version":1,"bindings":{"approval.select-deny":["g g"]}}"#, + ) + .unwrap(); + let mut runtime = crate::keybindings::KeymapRuntime::new(keymap); + let owner = crate::keybindings::InputOwnerFingerprint { + context: ShortcutContext::Approval, + modal: crate::keybindings::ModalOwner::Approval, + panel: crate::types::PanelMode::Conversation, + vim_mode: None, + }; + let now = std::time::Instant::now(); + let key = KeyEvent::new(KeyCode::Char('g'), crossterm::event::KeyModifiers::NONE); + + assert!(handle_approval_dialog_key_dynamic( + &key, + now, + owner, + &mut runtime, + &mut state, + &action_tx, + )); + let continuation = + runtime.advance_pending(owner, key, now + std::time::Duration::from_millis(1)); + let crate::keybindings::ShortcutResolution::Action(invocation) = continuation else { + panic!("expected completed approval chord"); + }; + let ShortcutAction::Approval(shortcut) = invocation.action else { + panic!("expected approval action"); + }; + handle_approval_shortcut(shortcut, &mut state, &action_tx); + assert_eq!(state.approval_dialog.as_ref().unwrap().selected, 1); } } diff --git a/crates/orca-tui/src/capability_backend.rs b/crates/orca-tui/src/capability_backend.rs new file mode 100644 index 00000000..e2660711 --- /dev/null +++ b/crates/orca-tui/src/capability_backend.rs @@ -0,0 +1,512 @@ +#![cfg_attr(not(test), allow(dead_code))] + +use std::io; +use std::ops::Range; + +use ratatui::backend::{Backend, ClearType, WindowSize}; +use ratatui::buffer::Cell; +use ratatui::layout::{Position, Size}; + +use crate::terminal_capabilities::TerminalColorLevel; + +pub(crate) struct CapabilityBackend { + inner: B, + color_level: TerminalColorLevel, +} + +impl CapabilityBackend { + pub(crate) const fn new(inner: B, color_level: TerminalColorLevel) -> Self { + Self { inner, color_level } + } + + pub(crate) const fn inner(&self) -> &B { + &self.inner + } + + pub(crate) fn inner_mut(&mut self) -> &mut B { + &mut self.inner + } + + #[cfg(test)] + pub(crate) const fn color_level_for_test(&self) -> TerminalColorLevel { + self.color_level + } +} + +impl Backend for CapabilityBackend { + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + if self.color_level == TerminalColorLevel::TrueColor { + return self.inner.draw(content); + } + + let adapted = content + .map(|(x, y, cell)| { + let mut cell = cell.clone(); + cell.set_style(self.color_level.adapt_style(cell.style())); + (x, y, cell) + }) + .collect::>(); + self.inner + .draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell))) + } + + fn append_lines(&mut self, line_count: u16) -> io::Result<()> { + self.inner.append_lines(line_count) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + self.inner.hide_cursor() + } + + fn show_cursor(&mut self) -> io::Result<()> { + self.inner.show_cursor() + } + + fn get_cursor_position(&mut self) -> io::Result { + self.inner.get_cursor_position() + } + + fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + self.inner.set_cursor_position(position) + } + + #[allow(deprecated)] + fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + self.inner.get_cursor() + } + + #[allow(deprecated)] + fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + self.inner.set_cursor(x, y) + } + + fn clear(&mut self) -> io::Result<()> { + self.inner.clear() + } + + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + self.inner.clear_region(clear_type) + } + + fn size(&self) -> io::Result { + self.inner.size() + } + + fn window_size(&mut self) -> io::Result { + self.inner.window_size() + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } + + fn scroll_region_up(&mut self, region: Range, line_count: u16) -> io::Result<()> { + self.inner.scroll_region_up(region, line_count) + } + + fn scroll_region_down(&mut self, region: Range, line_count: u16) -> io::Result<()> { + self.inner.scroll_region_down(region, line_count) + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::io; + use std::ops::Range; + + use ratatui::backend::{Backend, ClearType, WindowSize}; + use ratatui::buffer::Cell; + use ratatui::layout::{Position, Size}; + use ratatui::style::{Color, Modifier, Style}; + + use super::CapabilityBackend; + use crate::terminal_capabilities::TerminalColorLevel; + + #[derive(Debug, Eq, PartialEq)] + enum BackendCall { + AppendLines(u16), + HideCursor, + ShowCursor, + GetCursorPosition, + SetCursorPosition(Position), + GetCursor, + SetCursor(u16, u16), + Clear, + ClearRegion(ClearType), + Size, + WindowSize, + Flush, + ScrollRegionUp(Range, u16), + ScrollRegionDown(Range, u16), + } + + struct RecordingBackend { + drawn: Vec<(u16, u16, Cell)>, + calls: RefCell>, + cursor_position: Position, + cursor: (u16, u16), + size: Size, + window_size: WindowSize, + } + + impl Default for RecordingBackend { + fn default() -> Self { + Self { + drawn: Vec::new(), + calls: RefCell::new(Vec::new()), + cursor_position: Position { x: 5, y: 7 }, + cursor: (23, 29), + size: Size::new(80, 24), + window_size: WindowSize { + columns_rows: Size::new(80, 24), + pixels: Size::new(800, 480), + }, + } + } + } + + impl Backend for RecordingBackend { + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + self.drawn + .extend(content.map(|(x, y, cell)| (x, y, cell.clone()))); + Ok(()) + } + + fn append_lines(&mut self, line_count: u16) -> io::Result<()> { + self.calls + .borrow_mut() + .push(BackendCall::AppendLines(line_count)); + Ok(()) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + self.calls.borrow_mut().push(BackendCall::HideCursor); + Ok(()) + } + + fn show_cursor(&mut self) -> io::Result<()> { + self.calls.borrow_mut().push(BackendCall::ShowCursor); + Ok(()) + } + + fn get_cursor_position(&mut self) -> io::Result { + self.calls.borrow_mut().push(BackendCall::GetCursorPosition); + Ok(self.cursor_position) + } + + fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + let position = position.into(); + self.calls + .borrow_mut() + .push(BackendCall::SetCursorPosition(position)); + Ok(()) + } + + #[allow(deprecated)] + fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + self.calls.borrow_mut().push(BackendCall::GetCursor); + Ok(self.cursor) + } + + #[allow(deprecated)] + fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { + self.calls.borrow_mut().push(BackendCall::SetCursor(x, y)); + Ok(()) + } + + fn clear(&mut self) -> io::Result<()> { + self.calls.borrow_mut().push(BackendCall::Clear); + Ok(()) + } + + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + self.calls + .borrow_mut() + .push(BackendCall::ClearRegion(clear_type)); + Ok(()) + } + + fn size(&self) -> io::Result { + self.calls.borrow_mut().push(BackendCall::Size); + Ok(self.size) + } + + fn window_size(&mut self) -> io::Result { + self.calls.borrow_mut().push(BackendCall::WindowSize); + Ok(self.window_size) + } + + fn flush(&mut self) -> io::Result<()> { + self.calls.borrow_mut().push(BackendCall::Flush); + Ok(()) + } + + fn scroll_region_up(&mut self, region: Range, line_count: u16) -> io::Result<()> { + self.calls + .borrow_mut() + .push(BackendCall::ScrollRegionUp(region, line_count)); + Ok(()) + } + + fn scroll_region_down(&mut self, region: Range, line_count: u16) -> io::Result<()> { + self.calls + .borrow_mut() + .push(BackendCall::ScrollRegionDown(region, line_count)); + Ok(()) + } + } + + #[derive(Default)] + struct FailingBackend; + + impl FailingBackend { + fn error() -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, "injected backend failure") + } + } + + impl Backend for FailingBackend { + fn draw<'a, I>(&mut self, _content: I) -> io::Result<()> + where + I: Iterator, + { + Err(Self::error()) + } + + fn append_lines(&mut self, _line_count: u16) -> io::Result<()> { + Err(Self::error()) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + Err(Self::error()) + } + + fn show_cursor(&mut self) -> io::Result<()> { + Err(Self::error()) + } + + fn get_cursor_position(&mut self) -> io::Result { + Err(Self::error()) + } + + fn set_cursor_position>(&mut self, _position: P) -> io::Result<()> { + Err(Self::error()) + } + + #[allow(deprecated)] + fn get_cursor(&mut self) -> io::Result<(u16, u16)> { + Err(Self::error()) + } + + #[allow(deprecated)] + fn set_cursor(&mut self, _x: u16, _y: u16) -> io::Result<()> { + Err(Self::error()) + } + + fn clear(&mut self) -> io::Result<()> { + Err(Self::error()) + } + + fn clear_region(&mut self, _clear_type: ClearType) -> io::Result<()> { + Err(Self::error()) + } + + fn size(&self) -> io::Result { + Err(Self::error()) + } + + fn window_size(&mut self) -> io::Result { + Err(Self::error()) + } + + fn flush(&mut self) -> io::Result<()> { + Err(Self::error()) + } + + fn scroll_region_up(&mut self, _region: Range, _line_count: u16) -> io::Result<()> { + Err(Self::error()) + } + + fn scroll_region_down(&mut self, _region: Range, _line_count: u16) -> io::Result<()> { + Err(Self::error()) + } + } + + fn assert_injected_error(result: io::Result) { + let error = result.err().expect("injected backend error"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(error.to_string(), "injected backend failure"); + } + + fn cell_colors_fit(level: TerminalColorLevel, cell: &Cell) -> bool { + let color_fits = |color| match level { + TerminalColorLevel::TrueColor => true, + TerminalColorLevel::Ansi256 => !matches!(color, Color::Rgb(..)), + TerminalColorLevel::Ansi16 => !matches!(color, Color::Rgb(..) | Color::Indexed(_)), + TerminalColorLevel::Monochrome => color == Color::Reset, + }; + + color_fits(cell.fg) && color_fits(cell.bg) && color_fits(cell.underline_color) + } + + #[test] + fn capability_backend_adapts_changed_cells_and_preserves_metadata() { + let mut source = Cell::default(); + source.set_symbol("界"); + source.set_style( + Style::default() + .fg(Color::Rgb(255, 0, 0)) + .bg(Color::Indexed(42)) + .underline_color(Color::Rgb(0, 255, 0)) + .add_modifier(Modifier::BOLD), + ); + source.set_skip(true); + + for level in [ + TerminalColorLevel::Ansi256, + TerminalColorLevel::Ansi16, + TerminalColorLevel::Monochrome, + ] { + let recorder = RecordingBackend::default(); + let mut backend = CapabilityBackend::new(recorder, level); + backend.draw(std::iter::once((3, 4, &source))).unwrap(); + + let drawn = &backend.inner().drawn[0]; + assert_eq!((drawn.0, drawn.1), (3, 4)); + assert_eq!(drawn.2.symbol(), "界"); + assert_eq!(drawn.2.modifier, Modifier::BOLD); + assert!(drawn.2.skip); + assert!(cell_colors_fit(level, &drawn.2)); + } + } + + #[test] + fn theme_preview_never_changes_capability_backend_color_level() { + let backend = + CapabilityBackend::new(RecordingBackend::default(), TerminalColorLevel::Ansi16); + + assert_eq!(backend.color_level_for_test(), TerminalColorLevel::Ansi16,); + } + + #[test] + fn capability_backend_adapts_true_color_without_changing_the_cell() { + let mut source = Cell::default(); + source.set_symbol("界"); + source.set_style( + Style::default() + .fg(Color::Rgb(1, 2, 3)) + .bg(Color::Indexed(42)) + .underline_color(Color::Rgb(4, 5, 6)) + .add_modifier(Modifier::BOLD | Modifier::ITALIC), + ); + source.set_skip(true); + + let mut backend = + CapabilityBackend::new(RecordingBackend::default(), TerminalColorLevel::TrueColor); + backend.draw(std::iter::once((3, 4, &source))).unwrap(); + + assert_eq!(backend.inner().drawn, vec![(3, 4, source)]); + } + + #[test] + #[allow(deprecated)] + fn capability_backend_delegates_deprecated_cursor_aliases_exactly() { + let mut backend = + CapabilityBackend::new(RecordingBackend::default(), TerminalColorLevel::TrueColor); + + assert_eq!(backend.get_cursor().unwrap(), (23, 29)); + backend.set_cursor(31, 37).unwrap(); + + assert_eq!( + *backend.inner().calls.borrow(), + vec![BackendCall::GetCursor, BackendCall::SetCursor(31, 37)] + ); + } + + #[test] + fn capability_backend_delegates_complete_backend_contract() { + let mut backend = + CapabilityBackend::new(RecordingBackend::default(), TerminalColorLevel::Ansi16); + backend.inner_mut().cursor_position = Position { x: 11, y: 13 }; + + backend.append_lines(2).unwrap(); + backend.hide_cursor().unwrap(); + backend.show_cursor().unwrap(); + assert_eq!( + backend.get_cursor_position().unwrap(), + Position { x: 11, y: 13 } + ); + backend + .set_cursor_position(Position { x: 17, y: 19 }) + .unwrap(); + backend.clear().unwrap(); + backend.clear_region(ClearType::CurrentLine).unwrap(); + assert_eq!(backend.size().unwrap(), Size::new(80, 24)); + assert_eq!( + backend.window_size().unwrap(), + WindowSize { + columns_rows: Size::new(80, 24), + pixels: Size::new(800, 480), + } + ); + backend.flush().unwrap(); + backend.scroll_region_up(3..9, 2).unwrap(); + backend.scroll_region_down(4..12, 3).unwrap(); + + assert_eq!( + *backend.inner().calls.borrow(), + vec![ + BackendCall::AppendLines(2), + BackendCall::HideCursor, + BackendCall::ShowCursor, + BackendCall::GetCursorPosition, + BackendCall::SetCursorPosition(Position { x: 17, y: 19 }), + BackendCall::Clear, + BackendCall::ClearRegion(ClearType::CurrentLine), + BackendCall::Size, + BackendCall::WindowSize, + BackendCall::Flush, + BackendCall::ScrollRegionUp(3..9, 2), + BackendCall::ScrollRegionDown(4..12, 3), + ] + ); + } + + #[test] + fn capability_backend_preserves_draw_errors_for_direct_and_degraded_paths() { + let source = Cell::default(); + + for level in [TerminalColorLevel::TrueColor, TerminalColorLevel::Ansi16] { + let mut backend = CapabilityBackend::new(FailingBackend, level); + assert_injected_error(backend.draw(std::iter::once((3, 4, &source)))); + } + } + + #[test] + #[allow(deprecated)] + fn capability_backend_preserves_delegated_backend_errors() { + let mut backend = CapabilityBackend::new(FailingBackend, TerminalColorLevel::TrueColor); + + assert_injected_error(backend.append_lines(2)); + assert_injected_error(backend.hide_cursor()); + assert_injected_error(backend.show_cursor()); + assert_injected_error(backend.get_cursor_position()); + assert_injected_error(backend.set_cursor_position(Position { x: 3, y: 5 })); + assert_injected_error(backend.get_cursor()); + assert_injected_error(backend.set_cursor(7, 11)); + assert_injected_error(backend.clear()); + assert_injected_error(backend.clear_region(ClearType::AfterCursor)); + assert_injected_error(backend.size()); + assert_injected_error(backend.window_size()); + assert_injected_error(backend.flush()); + assert_injected_error(backend.scroll_region_up(3..9, 2)); + assert_injected_error(backend.scroll_region_down(4..12, 3)); + } +} diff --git a/crates/orca-tui/src/commands/mod.rs b/crates/orca-tui/src/commands/mod.rs index 02a02b80..2a8aa9de 100644 --- a/crates/orca-tui/src/commands/mod.rs +++ b/crates/orca-tui/src/commands/mod.rs @@ -4,6 +4,7 @@ pub enum SlashCommand { Compact, Cost, ConfigShow, + Doctor(DoctorSlashCommand), History, Mode(Option), Plan(Option), @@ -34,6 +35,13 @@ pub enum GoalSlashCommand { Resume, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DoctorSlashCommand { + Report, + ToggleFps, + SetFps(bool), +} + pub fn parse(input: &str) -> Option { parse_static(input) } @@ -47,7 +55,7 @@ pub fn parse_with_cwd(input: &str, cwd: &Path) -> Option { let rest = trimmed.strip_prefix('/')?; let mut parts = rest.split_whitespace(); let command = parts.next()?; - if builtin_command_names().contains(command) { + if has_builtin_name(input) { return None; } let args = parts.collect::>().join(" "); @@ -78,6 +86,14 @@ pub fn parse_with_cwd(input: &str, cwd: &Path) -> Option { None } +pub(crate) fn has_builtin_name(input: &str) -> bool { + input + .trim() + .strip_prefix('/') + .and_then(|rest| rest.split_whitespace().next()) + .is_some_and(|command| builtin_command_names().contains(command)) +} + fn parse_static(input: &str) -> Option { let trimmed = input.trim(); let rest = trimmed.strip_prefix('/')?; @@ -90,6 +106,16 @@ fn parse_static(input: &str) -> Option { "compact" => Some(SlashCommand::Compact), "cost" => Some(SlashCommand::Cost), "config" if parts.next() == Some("show") => Some(SlashCommand::ConfigShow), + "doctor" => { + let args = parts.collect::>(); + match args.as_slice() { + [] => Some(SlashCommand::Doctor(DoctorSlashCommand::Report)), + ["fps"] => Some(SlashCommand::Doctor(DoctorSlashCommand::ToggleFps)), + ["fps", "on"] => Some(SlashCommand::Doctor(DoctorSlashCommand::SetFps(true))), + ["fps", "off"] => Some(SlashCommand::Doctor(DoctorSlashCommand::SetFps(false))), + _ => None, + } + } "history" => Some(SlashCommand::History), "mode" => Some(SlashCommand::Mode( parts.next().map(|mode| mode.to_string()), @@ -135,6 +161,10 @@ pub fn all_commands() -> &'static [(&'static str, &'static str)] { ("/compact", "Compress conversation context"), ("/cost", "Show session cost"), ("/config show", "Show merged config"), + ( + "/doctor", + "Show terminal diagnostics and control the FPS HUD", + ), ("/mode", "Switch approval mode"), ("/plan", "Toggle plan mode"), ("/goal", "Manage a persistent goal"), @@ -454,6 +484,58 @@ mod tests { assert_eq!(parse("/config show"), Some(SlashCommand::ConfigShow)); } + #[test] + fn parses_doctor_commands_exactly() { + assert_eq!( + parse("/doctor"), + Some(SlashCommand::Doctor(DoctorSlashCommand::Report)) + ); + assert_eq!( + parse("/doctor fps"), + Some(SlashCommand::Doctor(DoctorSlashCommand::ToggleFps)) + ); + assert_eq!( + parse("/doctor fps on"), + Some(SlashCommand::Doctor(DoctorSlashCommand::SetFps(true))) + ); + assert_eq!( + parse("/doctor fps off"), + Some(SlashCommand::Doctor(DoctorSlashCommand::SetFps(false))) + ); + } + + #[test] + fn rejects_malformed_doctor_commands() { + for input in [ + "/doctor on", + "/doctor fps true", + "/doctor fps off extra", + "/doctor extra", + "/Doctor", + ] { + assert_eq!(parse(input), None, "{input}"); + } + } + + #[test] + fn malformed_doctor_commands_still_reserve_the_builtin_name() { + for input in ["/doctor extra", "/doctor fps true"] { + assert!(has_builtin_name(input), "{input}"); + } + } + + #[test] + fn doctor_is_one_builtin_menu_row_and_cannot_be_shadowed() { + assert_eq!( + all_commands() + .iter() + .filter(|(name, _)| *name == "/doctor") + .count(), + 1, + ); + assert!(builtin_command_names().contains("doctor")); + } + #[test] fn parses_remember_command() { assert_eq!( diff --git a/crates/orca-tui/src/composer_input_actions.rs b/crates/orca-tui/src/composer_input_actions.rs index e8bf5401..0291ae41 100644 --- a/crates/orca-tui/src/composer_input_actions.rs +++ b/crates/orca-tui/src/composer_input_actions.rs @@ -10,11 +10,15 @@ use crate::composer_textarea::{ }; use crate::slash_menu_actions::update_slash_menu; use crate::theme::Theme; -use crate::types::AppState; +use crate::types::{AppState, AppStatus}; use crate::vim::VimState; pub(crate) fn refresh_input_menus(textarea: &TextArea, state: &mut AppState, config: &RunConfig) { - update_slash_menu(textarea, state, config); + if state.status == AppStatus::Idle { + update_slash_menu(textarea, state, config); + } else { + state.slash_menu = None; + } } pub(crate) fn insert_composer_newline(textarea: &mut TextArea, state: &mut AppState) { @@ -65,6 +69,7 @@ pub(crate) fn apply_composer_key_input( theme: &Theme, ) -> bool { let changed = if key.code == KeyCode::Tab { + vim_state.cancel_pending_command(); let text = textarea_text(textarea); let cursor = textarea_cursor_byte_index(textarea); let candidates = state @@ -104,3 +109,93 @@ pub(crate) fn apply_composer_key_input( } changed } + +#[cfg(test)] +mod tests { + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + use orca_core::config::ThemeName; + + use super::*; + use crate::composer_textarea::{make_textarea_with_text, textarea_text}; + use crate::types::{AppState, AppStatus}; + + #[test] + fn running_slash_text_never_opens_local_command_menu() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.status = AppStatus::Running; + let config = crate::test_support::test_run_config(); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("/compac", &vim, &theme); + let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE); + + apply_composer_key_input( + &Event::Key(key), + &key, + &mut state, + &config, + &mut textarea, + &mut vim, + &theme, + ); + + assert_eq!(textarea_text(&textarea), "/compact"); + assert!(state.slash_menu.is_none()); + } + + #[test] + fn tab_clears_pending_vim_prefix_before_direct_textarea_input() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = crate::test_support::test_run_config(); + config.vim_mode = true; + let theme = Theme::named(ThemeName::Dark); + let mut expected_vim = VimState::new(true); + let mut expected = make_textarea_with_text("abcd", &expected_vim, &theme); + expected.move_cursor(tui_textarea::CursorMove::Head); + let mut vim = VimState::new(true); + let mut textarea = make_textarea_with_text("abcd", &vim, &theme); + textarea.move_cursor(tui_textarea::CursorMove::Head); + + for code in [KeyCode::Tab, KeyCode::Char('x')] { + let key = KeyEvent::new(code, KeyModifiers::NONE); + apply_composer_key_input( + &Event::Key(key), + &key, + &mut state, + &config, + &mut expected, + &mut expected_vim, + &theme, + ); + } + + for code in [KeyCode::Char('2'), KeyCode::Tab, KeyCode::Char('x')] { + let key = KeyEvent::new(code, KeyModifiers::NONE); + apply_composer_key_input( + &Event::Key(key), + &key, + &mut state, + &config, + &mut textarea, + &mut vim, + &theme, + ); + } + + assert_eq!(textarea_text(&textarea), textarea_text(&expected)); + assert_eq!(textarea.cursor(), expected.cursor()); + assert!(!vim.has_pending_command_for_test()); + } +} diff --git a/crates/orca-tui/src/composer_textarea.rs b/crates/orca-tui/src/composer_textarea.rs index c59c64f4..aaccf7af 100644 --- a/crates/orca-tui/src/composer_textarea.rs +++ b/crates/orca-tui/src/composer_textarea.rs @@ -106,11 +106,7 @@ pub(crate) fn insert_composer_paste( } let visible_text = textarea_text(textarea); - let active_placeholders = locate_pending_pastes(&visible_text, pending_pastes) - .into_iter() - .map(|(_, _, index)| pending_pastes[index].0.clone()) - .collect::>(); - pending_pastes.retain(|(placeholder, _)| active_placeholders.contains(placeholder)); + retain_active_pending_pastes(&visible_text, pending_pastes); let placeholder = next_large_paste_placeholder(pending_pastes, char_count); if !textarea.insert_str(&placeholder) { return false; @@ -158,6 +154,37 @@ pub(crate) fn expand_pending_pastes( expanded } +pub(crate) fn expand_pending_pastes_with_bindings( + visible_text: &str, + pending_pastes: &[(String, String)], + bindings: &mut orca_runtime::mentions::MentionBindings, +) -> String { + let mut replacements = locate_pending_pastes(visible_text, pending_pastes) + .into_iter() + .map(|(start, end, index)| (start, end, pending_pastes[index].1.as_str())) + .collect::>(); + replacements.sort_unstable_by_key(|(start, _, _)| *start); + + let mut expanded = visible_text.to_string(); + bindings.reconcile(&expanded); + for (start, end, actual) in replacements.into_iter().rev() { + expanded.replace_range(start..end, actual); + bindings.reconcile(&expanded); + } + expanded +} + +pub(crate) fn retain_active_pending_pastes( + visible_text: &str, + pending_pastes: &mut Vec<(String, String)>, +) { + let active_placeholders = locate_pending_pastes(visible_text, pending_pastes) + .into_iter() + .map(|(_, _, index)| pending_pastes[index].0.clone()) + .collect::>(); + pending_pastes.retain(|(placeholder, _)| active_placeholders.contains(placeholder)); +} + fn locate_pending_pastes( visible_text: &str, pending_pastes: &[(String, String)], diff --git a/crates/orca-tui/src/diagnostics.rs b/crates/orca-tui/src/diagnostics.rs new file mode 100644 index 00000000..c1f33ba0 --- /dev/null +++ b/crates/orca-tui/src/diagnostics.rs @@ -0,0 +1,1053 @@ +use std::collections::VecDeque; +use std::fmt::Write as _; +use std::time::{Duration, Instant}; + +use orca_core::config::ThemeName; + +use crate::terminal_capabilities::{TerminalBackground, TerminalColorLevel, TerminalProfile}; +use crate::terminal_presentation::TerminalPresentationProfile; + +const FRAME_SAMPLE_CAPACITY: usize = 120; +const FPS_WINDOW: Duration = Duration::from_secs(2); +const MAX_RENDER_DURATION: Duration = Duration::from_secs(1); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeybindingsLocation { + DefaultHome, + OrcaHome, + Unavailable, +} + +impl KeybindingsLocation { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::DefaultHome => "default-home", + Self::OrcaHome => "orca-home", + Self::Unavailable => "unavailable", + } + } +} + +pub(crate) struct SnapshotInput<'a> { + pub(crate) app_version: &'a str, + pub(crate) terminal_identity: &'a qwertty::TerminalIdentity, + pub(crate) terminal_profile: TerminalProfile, + pub(crate) presentation_profile: TerminalPresentationProfile, + pub(crate) requested_theme: ThemeName, + pub(crate) resolved_theme: ThemeName, + pub(crate) terminal_notifications: bool, + pub(crate) desktop_notifications: bool, + pub(crate) focus_events_requested: bool, + pub(crate) vim_mode: bool, + pub(crate) keybindings_location: KeybindingsLocation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiagnosticSnapshot { + app_version: String, + os: &'static str, + arch: &'static str, + terminal_program: String, + terminal_version: Option, + multiplexers: Vec, + color_level: TerminalColorLevel, + background: TerminalBackground, + requested_theme: ThemeName, + resolved_theme: ThemeName, + osc9_supported: bool, + tmux_passthrough: bool, + focus_events_requested: bool, + terminal_notifications: bool, + desktop_notifications: bool, + vim_enabled: bool, + keybindings_location: KeybindingsLocation, +} + +impl DiagnosticSnapshot { + pub(crate) fn new(input: SnapshotInput<'_>) -> Self { + let terminal_program = input + .terminal_identity + .program + .as_ref() + .map(ToString::to_string) + .map(|value| bounded_diagnostic_text(&value)) + .unwrap_or_else(|| "unknown".to_string()); + let terminal_version = input + .terminal_identity + .version + .as_deref() + .map(bounded_diagnostic_text) + .filter(|value| value != "unknown"); + let multiplexers = input + .terminal_identity + .mux_stack + .iter() + .take(4) + .map(multiplexer_label) + .map(str::to_string) + .collect(); + Self { + app_version: bounded_diagnostic_text(input.app_version), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + terminal_program, + terminal_version, + multiplexers, + color_level: input.terminal_profile.color_level, + background: input.terminal_profile.background, + requested_theme: input.requested_theme, + resolved_theme: input.resolved_theme, + osc9_supported: input.presentation_profile.osc9_supported(), + tmux_passthrough: input.presentation_profile.tmux_passthrough(), + focus_events_requested: input.focus_events_requested, + terminal_notifications: input.terminal_notifications, + desktop_notifications: input.desktop_notifications, + vim_enabled: input.vim_mode, + keybindings_location: input.keybindings_location, + } + } + + pub(crate) fn set_theme_projection(&mut self, requested: ThemeName, resolved: ThemeName) { + self.requested_theme = requested; + self.resolved_theme = resolved; + } + + #[cfg(test)] + pub(crate) fn terminal_program(&self) -> &str { + &self.terminal_program + } + + #[cfg(test)] + pub(crate) fn terminal_version(&self) -> Option<&str> { + self.terminal_version.as_deref() + } + + #[cfg(test)] + pub(crate) fn multiplexers(&self) -> &[String] { + &self.multiplexers + } + + #[cfg(test)] + pub(crate) const fn keybindings_location(&self) -> KeybindingsLocation { + self.keybindings_location + } + + #[cfg(test)] + pub(crate) const fn color_level(&self) -> TerminalColorLevel { + self.color_level + } + + #[cfg(test)] + pub(crate) const fn requested_theme(&self) -> ThemeName { + self.requested_theme + } + + #[cfg(test)] + pub(crate) const fn resolved_theme(&self) -> ThemeName { + self.resolved_theme + } +} + +impl Default for DiagnosticSnapshot { + fn default() -> Self { + Self { + app_version: "unknown".to_string(), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + terminal_program: "unknown".to_string(), + terminal_version: None, + multiplexers: Vec::new(), + color_level: TerminalColorLevel::Monochrome, + background: TerminalBackground::Unknown, + requested_theme: ThemeName::Auto, + resolved_theme: ThemeName::Dark, + osc9_supported: false, + tmux_passthrough: false, + focus_events_requested: false, + terminal_notifications: false, + desktop_notifications: false, + vim_enabled: false, + keybindings_location: KeybindingsLocation::Unavailable, + } + } +} + +fn multiplexer_label(multiplexer: &qwertty::Multiplexer) -> &'static str { + match multiplexer { + qwertty::Multiplexer::Tmux => "tmux", + qwertty::Multiplexer::Screen => "screen", + qwertty::Multiplexer::Zellij => "zellij", + _ => "unknown", + } +} + +pub(crate) fn bounded_diagnostic_text(source: &str) -> String { + let sanitized = source + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::(); + let collapsed = sanitized.split_whitespace().collect::>().join(" "); + let bounded = collapsed.chars().take(160).collect::(); + if bounded.is_empty() { + "unknown".to_string() + } else { + bounded + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct FpsHudSnapshot { + pub(crate) fps: f64, + pub(crate) render_ms: f64, + pub(crate) p95_ms: f64, + pub(crate) total_draws: u64, + pub(crate) input_events: u64, + pub(crate) runtime_events: u64, +} + +impl FpsHudSnapshot { + pub(crate) fn hud_text(self) -> String { + format!( + " FPS {:.1} · {:.1}ms · p95 {:.1}ms ", + self.fps, self.render_ms, self.p95_ms + ) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeybindingsActive { + BuiltIns, + Custom, +} + +impl KeybindingsActive { + const fn as_str(self) -> &'static str { + match self { + Self::BuiltIns => "built-ins", + Self::Custom => "custom", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeybindingsReload { + Ok, + Rejected, + Restored, +} + +impl KeybindingsReload { + const fn as_str(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Rejected => "rejected", + Self::Restored => "restored", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct KeybindingsDiagnostic { + pub(crate) active: KeybindingsActive, + pub(crate) generation: u64, + pub(crate) reload: KeybindingsReload, +} + +impl Default for KeybindingsDiagnostic { + fn default() -> Self { + Self { + active: KeybindingsActive::BuiltIns, + generation: 0, + reload: KeybindingsReload::Ok, + } + } +} + +impl KeybindingsDiagnostic { + pub(crate) const fn built_ins(generation: u64) -> Self { + Self { + active: KeybindingsActive::BuiltIns, + generation, + reload: KeybindingsReload::Ok, + } + } + + pub(crate) fn applied_custom(&mut self, generation: u64) { + self.active = KeybindingsActive::Custom; + self.generation = generation; + self.reload = KeybindingsReload::Ok; + } + + pub(crate) fn restored_built_ins(&mut self, generation: u64) { + self.active = KeybindingsActive::BuiltIns; + self.generation = generation; + self.reload = KeybindingsReload::Restored; + } + + pub(crate) fn rejected(&mut self, generation: u64) { + self.generation = generation; + self.reload = KeybindingsReload::Rejected; + } + + pub(crate) fn accepted_unchanged(&mut self, generation: u64) { + self.generation = generation; + self.reload = KeybindingsReload::Ok; + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct DiagnosticRuntimeView { + pub(crate) viewport: Option<(u16, u16)>, + pub(crate) status: crate::types::AppStatus, + pub(crate) panel: crate::types::PanelMode, + pub(crate) vim_mode: Option, + pub(crate) fps_hud_enabled: bool, + pub(crate) keybindings: KeybindingsDiagnostic, + pub(crate) auth_configured: bool, +} + +pub(crate) fn format_doctor_report( + snapshot: &DiagnosticSnapshot, + runtime: DiagnosticRuntimeView, + metrics: FpsHudSnapshot, +) -> String { + let terminal = match snapshot.terminal_version.as_ref() { + Some(version) => format!("{} {version}", snapshot.terminal_program), + None if snapshot.terminal_program == "unknown" => "unknown".to_string(), + None => format!("{} unknown", snapshot.terminal_program), + }; + let multiplexers = if snapshot.multiplexers.is_empty() { + "none".to_string() + } else { + snapshot.multiplexers.join(", ") + }; + let viewport = runtime + .viewport + .map(|(width, height)| format!("{width}x{height} cells")) + .unwrap_or_else(|| "unknown".to_string()); + let mut report = String::with_capacity(1024); + let _ = writeln!(report, "Orca diagnostics"); + let _ = writeln!(report, "version: {}", snapshot.app_version); + let _ = writeln!(report, "platform: {}/{}", snapshot.os, snapshot.arch); + let _ = writeln!(report, "terminal: {terminal}"); + let _ = writeln!(report, "multiplexers: {multiplexers}"); + let _ = writeln!(report, "viewport: {viewport}"); + let _ = writeln!(report, "color: {}", snapshot.color_level.as_str()); + let _ = writeln!(report, "background: {}", snapshot.background.as_str()); + let _ = writeln!( + report, + "theme: {} -> {}", + snapshot.requested_theme.as_str(), + snapshot.resolved_theme.as_str() + ); + let _ = writeln!( + report, + "notifications: terminal={} focus-events={} osc9={} tmux-passthrough={} desktop={}", + on_off(snapshot.terminal_notifications), + on_off(snapshot.focus_events_requested), + yes_no(snapshot.osc9_supported), + yes_no(snapshot.tmux_passthrough), + on_off(snapshot.desktop_notifications), + ); + let _ = writeln!( + report, + "input: qwertty mouse=button paste=bracketed kitty-keyboard=push-succeeded" + ); + let _ = writeln!( + report, + "session: status={} panel={} vim={} auth={}", + app_status_label(runtime.status), + panel_label(runtime.panel), + vim_mode_label(runtime.vim_mode), + if runtime.auth_configured { + "configured" + } else { + "missing" + }, + ); + let _ = writeln!( + report, + "keybindings: {} generation={} location={} reload={}", + runtime.keybindings.active.as_str(), + runtime.keybindings.generation, + snapshot.keybindings_location.as_str(), + runtime.keybindings.reload.as_str(), + ); + let _ = writeln!(report, "fps-hud: {}", on_off(runtime.fps_hud_enabled)); + let _ = write!( + report, + "frames: fps={:.1} render-ms={:.1} p95-ms={:.1} draws={} input-events={} runtime-events={}", + metrics.fps, + metrics.render_ms, + metrics.p95_ms, + metrics.total_draws, + metrics.input_events, + metrics.runtime_events, + ); + truncate_utf8_bytes(report, 4096) +} + +fn on_off(value: bool) -> &'static str { + if value { "on" } else { "off" } +} + +fn yes_no(value: bool) -> &'static str { + if value { "yes" } else { "no" } +} + +fn app_status_label(status: crate::types::AppStatus) -> &'static str { + match status { + crate::types::AppStatus::Setup => "setup", + crate::types::AppStatus::SessionPicker => "session-picker", + crate::types::AppStatus::Idle => "idle", + crate::types::AppStatus::Running => "running", + crate::types::AppStatus::Compacting => "compacting", + crate::types::AppStatus::WaitingApproval => "waiting-approval", + crate::types::AppStatus::WaitingUserInput => "waiting-user-input", + } +} + +fn panel_label(panel: crate::types::PanelMode) -> &'static str { + match panel { + crate::types::PanelMode::Conversation => "conversation", + crate::types::PanelMode::Workflows => "workflows", + crate::types::PanelMode::Agents => "agents", + } +} + +fn vim_mode_label(mode: Option) -> &'static str { + match mode { + None => "off", + Some(crate::vim::VimMode::Insert) => "insert", + Some(crate::vim::VimMode::Normal) => "normal", + Some(crate::vim::VimMode::Visual) => "visual", + } +} + +fn truncate_utf8_bytes(mut value: String, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value; + } + let mut boundary = max_bytes; + while !value.is_char_boundary(boundary) { + boundary = boundary.saturating_sub(1); + } + value.truncate(boundary); + value +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct FrameMetrics { + draw_times: VecDeque, + render_durations: VecDeque, + total_draws: u64, + input_events: u64, + runtime_events: u64, +} + +impl FrameMetrics { + pub(crate) fn record_successful_draw(&mut self, started_at: Instant, completed_at: Instant) { + self.draw_times.push_back(completed_at); + prune_draw_times(&mut self.draw_times, completed_at); + while self.draw_times.len() > FRAME_SAMPLE_CAPACITY { + self.draw_times.pop_front(); + } + + let duration = completed_at + .checked_duration_since(started_at) + .unwrap_or_default() + .min(MAX_RENDER_DURATION); + self.render_durations.push_back(duration); + while self.render_durations.len() > FRAME_SAMPLE_CAPACITY { + self.render_durations.pop_front(); + } + self.total_draws = self.total_draws.saturating_add(1); + } + + pub(crate) fn record_iteration(&mut self, input_events: usize, runtime_events: usize) { + self.input_events = self + .input_events + .saturating_add(saturating_usize_to_u64(input_events)); + self.runtime_events = self + .runtime_events + .saturating_add(saturating_usize_to_u64(runtime_events)); + } + + pub(crate) fn snapshot(&self, now: Instant) -> FpsHudSnapshot { + let draw_times = self + .draw_times + .iter() + .copied() + .filter(|timestamp| timestamp_in_window(*timestamp, now)) + .collect::>(); + let fps = match (draw_times.first(), draw_times.last()) { + (Some(first), Some(last)) if draw_times.len() >= 2 => { + let elapsed = last.checked_duration_since(*first).unwrap_or_default(); + if elapsed.is_zero() { + 0.0 + } else { + (draw_times.len() - 1) as f64 / elapsed.as_secs_f64() + } + } + _ => 0.0, + }; + let render_ms = self + .render_durations + .back() + .copied() + .unwrap_or_default() + .as_secs_f64() + * 1000.0; + let p95_ms = nearest_rank_p95(&self.render_durations).as_secs_f64() * 1000.0; + FpsHudSnapshot { + fps, + render_ms, + p95_ms, + total_draws: self.total_draws, + input_events: self.input_events, + runtime_events: self.runtime_events, + } + } + + pub(crate) fn reset_rolling(&mut self) { + self.draw_times.clear(); + self.render_durations.clear(); + } + + #[cfg(test)] + fn sample_lengths_for_test(&self) -> (usize, usize) { + (self.draw_times.len(), self.render_durations.len()) + } + + #[cfg(test)] + fn with_counters_for_test(total_draws: u64, input_events: u64, runtime_events: u64) -> Self { + Self { + total_draws, + input_events, + runtime_events, + ..Self::default() + } + } +} + +fn prune_draw_times(draw_times: &mut VecDeque, now: Instant) { + while draw_times + .front() + .is_some_and(|timestamp| !timestamp_in_window(*timestamp, now)) + { + draw_times.pop_front(); + } +} + +fn timestamp_in_window(timestamp: Instant, now: Instant) -> bool { + now.checked_duration_since(timestamp) + .is_none_or(|elapsed| elapsed <= FPS_WINDOW) +} + +fn nearest_rank_p95(durations: &VecDeque) -> Duration { + if durations.is_empty() { + return Duration::ZERO; + } + let mut sorted = durations.iter().copied().collect::>(); + sorted.sort_unstable(); + let rank = (sorted.len() * 95).div_ceil(100); + sorted[rank.saturating_sub(1)] +} + +fn saturating_usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use orca_core::config::ThemeName; + + use crate::terminal_capabilities::{TerminalBackground, TerminalColorLevel, TerminalProfile}; + use crate::terminal_presentation::TerminalPresentationProfile; + use crate::types::{AppStatus, PanelMode}; + use crate::vim::VimMode; + + use super::{ + DiagnosticRuntimeView, DiagnosticSnapshot, FpsHudSnapshot, KeybindingsActive, + KeybindingsDiagnostic, KeybindingsLocation, KeybindingsReload, SnapshotInput, + bounded_diagnostic_text, format_doctor_report, + }; + + #[test] + fn theme_projection_updates_requested_and_resolved_values() { + let mut snapshot = DiagnosticSnapshot::default(); + + snapshot.set_theme_projection(ThemeName::Auto, ThemeName::Light); + + assert_eq!(snapshot.requested_theme(), ThemeName::Auto); + assert_eq!(snapshot.resolved_theme(), ThemeName::Light); + } + + fn known_snapshot() -> DiagnosticSnapshot { + let identity = qwertty::caps::identity_from_env(None, |key| match key { + "TERM_PROGRAM" => Some("ghostty".to_string()), + "TERM_PROGRAM_VERSION" => Some("1.2.0".to_string()), + "TMUX" => Some("tmux-session".to_string()), + "ZELLIJ" => Some("0".to_string()), + _ => None, + }); + DiagnosticSnapshot::new(SnapshotInput { + app_version: "0.2.50", + terminal_identity: &identity, + terminal_profile: TerminalProfile { + background: TerminalBackground::Dark, + color_level: TerminalColorLevel::TrueColor, + }, + presentation_profile: TerminalPresentationProfile::from_identity(&identity), + requested_theme: ThemeName::Auto, + resolved_theme: ThemeName::Dark, + terminal_notifications: true, + desktop_notifications: false, + focus_events_requested: true, + vim_mode: true, + keybindings_location: KeybindingsLocation::DefaultHome, + }) + } + + #[test] + fn diagnostic_enum_labels_are_stable() { + assert_eq!(TerminalColorLevel::TrueColor.as_str(), "truecolor"); + assert_eq!(TerminalColorLevel::Ansi256.as_str(), "ansi256"); + assert_eq!(TerminalColorLevel::Ansi16.as_str(), "ansi16"); + assert_eq!(TerminalColorLevel::Monochrome.as_str(), "monochrome"); + assert_eq!(TerminalBackground::Dark.as_str(), "dark"); + assert_eq!(TerminalBackground::Light.as_str(), "light"); + assert_eq!(TerminalBackground::Unknown.as_str(), "unknown"); + } + + #[test] + fn bounded_diagnostic_text_removes_controls_collapses_space_and_truncates() { + let source = format!("\u{1b}]0;bad\u{7}\n{}\tend", "x".repeat(200)); + let text = bounded_diagnostic_text(&source); + + assert!(!text.chars().any(char::is_control)); + assert!(!text.contains(" ")); + assert_eq!(text.chars().count(), 160); + } + + #[test] + fn snapshot_from_identity_preserves_mux_order_without_absolute_paths() { + let identity = qwertty::caps::identity_from_env(None, |key| match key { + "TERM_PROGRAM" => Some("ghostty".to_string()), + "TERM_PROGRAM_VERSION" => Some("1.2.0".to_string()), + "TMUX" => Some("tmux-session".to_string()), + "ZELLIJ" => Some("0".to_string()), + _ => None, + }); + let snapshot = DiagnosticSnapshot::new(SnapshotInput { + app_version: "0.2.50", + terminal_identity: &identity, + terminal_profile: TerminalProfile { + background: TerminalBackground::Dark, + color_level: TerminalColorLevel::TrueColor, + }, + presentation_profile: TerminalPresentationProfile::from_identity(&identity), + requested_theme: ThemeName::Auto, + resolved_theme: ThemeName::Dark, + terminal_notifications: true, + desktop_notifications: false, + focus_events_requested: true, + vim_mode: true, + keybindings_location: KeybindingsLocation::DefaultHome, + }); + + assert_eq!(snapshot.terminal_program(), "Ghostty"); + assert_eq!(snapshot.terminal_version(), Some("1.2.0")); + assert_eq!(snapshot.multiplexers(), ["tmux", "zellij"]); + assert_eq!(snapshot.keybindings_location().as_str(), "default-home"); + assert_eq!(KeybindingsLocation::OrcaHome.as_str(), "orca-home"); + } + + #[test] + fn unknown_terminal_identity_is_sanitized() { + let identity = qwertty::caps::identity_from_env(Some("mystery\u{1b}]0;bad\u{7}"), |_| None); + let snapshot = DiagnosticSnapshot::new(SnapshotInput { + app_version: "test", + terminal_identity: &identity, + terminal_profile: TerminalProfile { + background: TerminalBackground::Unknown, + color_level: TerminalColorLevel::Monochrome, + }, + presentation_profile: TerminalPresentationProfile::from_identity(&identity), + requested_theme: ThemeName::Auto, + resolved_theme: ThemeName::Dark, + terminal_notifications: false, + desktop_notifications: false, + focus_events_requested: false, + vim_mode: false, + keybindings_location: KeybindingsLocation::Unavailable, + }); + + assert_eq!(snapshot.terminal_program(), "unknown"); + assert_eq!(snapshot.terminal_version(), Some("mystery ]0;bad")); + assert!( + !snapshot + .terminal_version() + .expect("unknown XTVERSION is retained as version evidence") + .chars() + .any(char::is_control) + ); + } + + #[test] + fn first_successful_draw_has_zero_fps_and_one_render_sample() { + let started = Instant::now(); + let mut metrics = super::FrameMetrics::default(); + metrics.record_successful_draw(started, started + Duration::from_millis(3)); + + let snapshot = metrics.snapshot(started + Duration::from_millis(3)); + + assert_eq!(snapshot.fps, 0.0); + assert_eq!(snapshot.render_ms, 3.0); + assert_eq!(snapshot.p95_ms, 3.0); + assert_eq!(snapshot.total_draws, 1); + } + + #[test] + fn sixty_even_draws_approach_sixty_fps() { + let start = Instant::now(); + let mut metrics = super::FrameMetrics::default(); + for frame in 0..60u64 { + let completed = start + Duration::from_micros(frame * 16_667); + metrics.record_successful_draw(completed - Duration::from_millis(2), completed); + } + + let snapshot = metrics.snapshot(start + Duration::from_micros(59 * 16_667)); + + assert!((snapshot.fps - 60.0).abs() < 0.2, "{}", snapshot.fps); + } + + #[test] + fn idle_snapshot_prunes_fps_without_a_new_draw() { + let start = Instant::now(); + let mut metrics = super::FrameMetrics::default(); + metrics.record_successful_draw(start, start + Duration::from_millis(1)); + metrics.record_successful_draw( + start + Duration::from_millis(15), + start + Duration::from_millis(16), + ); + + assert_eq!(metrics.snapshot(start + Duration::from_secs(3)).fps, 0.0,); + } + + #[test] + fn frame_metrics_are_bounded_clamped_and_percentile_stable() { + let start = Instant::now(); + let mut metrics = super::FrameMetrics::default(); + for index in 0..500u64 { + let completed = start + Duration::from_millis(index * 10); + let duration = [1, 2, 3, 100][index as usize % 4]; + metrics.record_successful_draw( + completed + .checked_sub(Duration::from_millis(duration)) + .unwrap_or(completed), + completed, + ); + } + assert_eq!(metrics.sample_lengths_for_test(), (120, 120)); + let snapshot = metrics.snapshot(start + Duration::from_millis(4_990)); + assert_eq!(snapshot.p95_ms, 100.0); + + metrics.record_successful_draw(start, start + Duration::from_secs(2)); + assert_eq!( + metrics.snapshot(start + Duration::from_secs(2)).render_ms, + 1000.0, + ); + } + + #[test] + fn reversed_clock_saturation_and_suspend_reset_are_safe() { + let now = Instant::now(); + let mut metrics = super::FrameMetrics::with_counters_for_test(u64::MAX, u64::MAX, u64::MAX); + metrics.record_successful_draw(now + Duration::from_secs(1), now); + metrics.record_iteration(usize::MAX, usize::MAX); + let before = metrics.snapshot(now); + assert_eq!(before.render_ms, 0.0); + assert_eq!(before.total_draws, u64::MAX); + assert_eq!(before.input_events, u64::MAX); + assert_eq!(before.runtime_events, u64::MAX); + + metrics.reset_rolling(); + let after = metrics.snapshot(now + Duration::from_secs(3)); + assert_eq!((after.fps, after.render_ms, after.p95_ms), (0.0, 0.0, 0.0),); + assert_eq!(after.total_draws, u64::MAX); + } + + #[test] + fn first_draw_after_suspend_restarts_rolling_samples_and_keeps_lifetime_counters() { + let start = Instant::now(); + let mut metrics = super::FrameMetrics::default(); + metrics.record_successful_draw(start, start + Duration::from_millis(2)); + metrics.record_iteration(3, 4); + metrics.reset_rolling(); + metrics.record_successful_draw( + start + Duration::from_secs(1), + start + Duration::from_secs(1) + Duration::from_millis(5), + ); + + let snapshot = metrics.snapshot(start + Duration::from_secs(1) + Duration::from_millis(5)); + assert_eq!(snapshot.fps, 0.0); + assert_eq!(snapshot.render_ms, 5.0); + assert_eq!(snapshot.p95_ms, 5.0); + assert_eq!(snapshot.total_draws, 2); + assert_eq!(snapshot.input_events, 3); + assert_eq!(snapshot.runtime_events, 4); + assert_eq!(metrics.sample_lengths_for_test(), (1, 1)); + } + + #[test] + fn doctor_report_has_fixed_safe_line_order_and_bounded_size() { + let report = format_doctor_report( + &known_snapshot(), + DiagnosticRuntimeView { + viewport: Some((120, 40)), + status: AppStatus::Idle, + panel: PanelMode::Conversation, + vim_mode: Some(VimMode::Normal), + fps_hud_enabled: false, + keybindings: KeybindingsDiagnostic { + active: KeybindingsActive::Custom, + generation: 2, + reload: KeybindingsReload::Ok, + }, + auth_configured: true, + }, + FpsHudSnapshot { + fps: 59.8, + render_ms: 2.3, + p95_ms: 4.1, + total_draws: 123, + input_events: 45, + runtime_events: 67, + }, + ); + + let expected_platform = format!( + "platform: {}/{}", + std::env::consts::OS, + std::env::consts::ARCH + ); + let lines = report.lines().collect::>(); + assert_eq!( + lines, + [ + "Orca diagnostics", + "version: 0.2.50", + expected_platform.as_str(), + "terminal: Ghostty 1.2.0", + "multiplexers: tmux, zellij", + "viewport: 120x40 cells", + "color: truecolor", + "background: dark", + "theme: auto -> dark", + "notifications: terminal=on focus-events=on osc9=yes tmux-passthrough=yes desktop=off", + "input: qwertty mouse=button paste=bracketed kitty-keyboard=push-succeeded", + "session: status=idle panel=conversation vim=normal auth=configured", + "keybindings: custom generation=2 location=default-home reload=ok", + "fps-hud: off", + "frames: fps=59.8 render-ms=2.3 p95-ms=4.1 draws=123 input-events=45 runtime-events=67", + ], + ); + assert!(report.len() <= 4096); + assert!(!report.contains('\u{1b}')); + for forbidden in ["DEEPSEEK_API_KEY", "sk-", "/Users/", "C:\\Users\\"] { + assert!(!report.contains(forbidden)); + } + } + + #[test] + fn doctor_report_unknown_and_projection_matrix_is_explicit() { + for (vim_mode, expected_vim) in [ + (None, "off"), + (Some(VimMode::Insert), "insert"), + (Some(VimMode::Normal), "normal"), + (Some(VimMode::Visual), "visual"), + ] { + for (active, reload, expected) in [ + ( + KeybindingsActive::BuiltIns, + KeybindingsReload::Ok, + "built-ins", + ), + (KeybindingsActive::Custom, KeybindingsReload::Ok, "custom"), + ( + KeybindingsActive::Custom, + KeybindingsReload::Rejected, + "reload=rejected", + ), + ( + KeybindingsActive::BuiltIns, + KeybindingsReload::Restored, + "reload=restored", + ), + ] { + let report = format_doctor_report( + &DiagnosticSnapshot::default(), + DiagnosticRuntimeView { + viewport: None, + status: AppStatus::Idle, + panel: PanelMode::Conversation, + vim_mode, + fps_hud_enabled: false, + keybindings: KeybindingsDiagnostic { + active, + generation: 0, + reload, + }, + auth_configured: false, + }, + FpsHudSnapshot::default(), + ); + assert!(report.contains("terminal: unknown")); + assert!(report.contains("viewport: unknown")); + assert!(report.contains(&format!("vim={expected_vim}"))); + assert!(report.contains(expected)); + assert!(report.len() <= 4096); + } + } + } + + #[test] + fn doctor_report_labels_missing_version_without_duplicating_unknown_terminal() { + let mut known_without_version = known_snapshot(); + known_without_version.terminal_version = None; + let runtime = DiagnosticRuntimeView { + viewport: None, + status: AppStatus::Idle, + panel: PanelMode::Conversation, + vim_mode: None, + fps_hud_enabled: false, + keybindings: KeybindingsDiagnostic::default(), + auth_configured: true, + }; + + let known = + format_doctor_report(&known_without_version, runtime, FpsHudSnapshot::default()); + let unknown = format_doctor_report( + &DiagnosticSnapshot::default(), + runtime, + FpsHudSnapshot::default(), + ); + + assert!(known.contains("terminal: Ghostty unknown")); + assert!(unknown.contains("terminal: unknown\n")); + assert!(!unknown.contains("terminal: unknown unknown")); + } + + #[test] + fn keybindings_diagnostic_projects_reload_outcomes_without_losing_active_state() { + let mut diagnostic = KeybindingsDiagnostic::built_ins(0); + assert_eq!( + diagnostic, + KeybindingsDiagnostic { + active: KeybindingsActive::BuiltIns, + generation: 0, + reload: KeybindingsReload::Ok, + }, + ); + + diagnostic.applied_custom(1); + assert_eq!( + diagnostic, + KeybindingsDiagnostic { + active: KeybindingsActive::Custom, + generation: 1, + reload: KeybindingsReload::Ok, + }, + ); + + diagnostic.rejected(1); + assert_eq!(diagnostic.active, KeybindingsActive::Custom); + assert_eq!(diagnostic.generation, 1); + assert_eq!(diagnostic.reload, KeybindingsReload::Rejected); + + diagnostic.restored_built_ins(2); + assert_eq!( + diagnostic, + KeybindingsDiagnostic { + active: KeybindingsActive::BuiltIns, + generation: 2, + reload: KeybindingsReload::Restored, + }, + ); + } + + #[test] + fn accepted_unchanged_reload_clears_rejected_without_changing_active_state() { + let mut diagnostic = KeybindingsDiagnostic::built_ins(0); + diagnostic.applied_custom(1); + diagnostic.rejected(1); + diagnostic.accepted_unchanged(1); + + assert_eq!(diagnostic.active, KeybindingsActive::Custom); + assert_eq!(diagnostic.generation, 1); + assert_eq!(diagnostic.reload, KeybindingsReload::Ok); + } + + #[test] + fn hud_text_is_stable_and_uses_render_duration() { + let text = FpsHudSnapshot { + fps: 59.84, + render_ms: 2.34, + p95_ms: 4.06, + ..Default::default() + } + .hud_text(); + + assert_eq!(text, " FPS 59.8 · 2.3ms · p95 4.1ms "); + } + + #[test] + fn readmes_document_doctor_and_session_only_fps_hud() { + for (name, readme) in [ + ("README.md", include_str!("../../../README.md")), + ("README.zh-CN.md", include_str!("../../../README.zh-CN.md")), + ] { + for required in [ + "/doctor", + "/doctor fps", + "/doctor fps on", + "/doctor fps off", + "session-only", + "default", + "secrets", + "re-probe", + ] { + assert!(readme.contains(required), "{name}: {required}"); + } + } + } + + #[test] + fn doctor_formatter_source_has_no_runtime_io_or_probe_calls() { + let source = include_str!("diagnostics.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production diagnostics source"); + for forbidden in [ + "std::fs", + "std::process", + "Command::new", + "std::env::var", + "std::env::var_os", + "probe_capabilities", + "probe_background", + "identity_from_env", + ] { + assert!(!source.contains(forbidden), "{forbidden}"); + } + } +} diff --git a/crates/orca-tui/src/diff_highlight.rs b/crates/orca-tui/src/diff_highlight.rs new file mode 100644 index 00000000..e4f55af0 --- /dev/null +++ b/crates/orca-tui/src/diff_highlight.rs @@ -0,0 +1,4369 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use similar::{ChangeTag, TextDiff}; + +use crate::syntax_highlight::{ + LineHighlighter, MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINES, StyledSourceLine, SyntaxTheme, + content_within_limits, highlighter_for_path, +}; +use crate::terminal_capabilities::TerminalColorLevel; +use crate::theme::Theme; + +const MAX_RENDERED_DIFF_LINES: usize = 80; +const TRUNCATION_MARKER: &str = " [... diff truncated ...]"; +const INLINE_DIFF_DEADLINE: Duration = Duration::from_millis(5); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DiffLineKind { + Context, + Insert, + Delete, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiffSourceLine { + pub(crate) kind: DiffLineKind, + pub(crate) old_line: Option, + pub(crate) new_line: Option, + pub(crate) content: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DiffHunkEntry { + Source(DiffSourceLine), + Metadata(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ReplacementCluster { + delete_start: usize, + delete_end: usize, + insert_start: usize, + insert_end: usize, +} + +fn replacement_clusters(entries: &[DiffHunkEntry]) -> Vec { + let mut clusters = Vec::new(); + let mut index = 0; + while index < entries.len() { + let delete_start = index; + while matches!( + entries.get(index), + Some(DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Delete, + .. + })) + ) { + index += 1; + } + if index == delete_start { + index += 1; + continue; + } + let insert_start = index; + while matches!( + entries.get(index), + Some(DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + .. + })) + ) { + index += 1; + } + if index > insert_start { + clusters.push(ReplacementCluster { + delete_start, + delete_end: insert_start, + insert_start, + insert_end: index, + }); + } + } + clusters +} + +type InlineSegmentMap = HashMap>; + +fn owned_inline_segments( + change: &similar::InlineChange<'_, str>, + expected: &str, +) -> Option> { + let mut segments = change + .iter_strings_lossy() + .map(|(emphasized, value)| (emphasized, value.into_owned())) + .collect::>(); + let last = segments.last_mut()?; + if let Some(without_newline) = last.1.strip_suffix('\n') { + last.1 = without_newline.to_owned(); + } + segments.retain(|(_, value)| !value.is_empty()); + (segments + .iter() + .map(|(_, value)| value.as_str()) + .collect::() + == expected) + .then_some(segments) +} + +fn cluster_inline_segments( + entries: &[DiffHunkEntry], + cluster: ReplacementCluster, + deadline: Instant, +) -> Option { + if Instant::now() >= deadline { + return None; + } + let old_lines = entries[cluster.delete_start..cluster.delete_end] + .iter() + .map(|entry| match entry { + DiffHunkEntry::Source(line) => Some(line.content.as_str()), + DiffHunkEntry::Metadata(_) => None, + }) + .collect::>>()?; + let new_lines = entries[cluster.insert_start..cluster.insert_end] + .iter() + .map(|entry| match entry { + DiffHunkEntry::Source(line) => Some(line.content.as_str()), + DiffHunkEntry::Metadata(_) => None, + }) + .collect::>>()?; + let old_text = format!("{}\n", old_lines.join("\n")); + let new_text = format!("{}\n", new_lines.join("\n")); + if !content_within_limits(&old_text) || !content_within_limits(&new_text) { + return None; + } + + let diff = TextDiff::from_lines(&old_text, &new_text); + let cooperative_deadline = Some(deadline); + let mut segments = InlineSegmentMap::new(); + for change in diff + .ops() + .iter() + .flat_map(|op| diff.iter_inline_changes_deadline(op, cooperative_deadline)) + { + match change.tag() { + ChangeTag::Delete => { + let old_index = change.old_index()?; + let entry_index = cluster.delete_start.checked_add(old_index)?; + let values = owned_inline_segments(&change, old_lines.get(old_index)?)?; + if segments.insert(entry_index, values).is_some() { + return None; + } + } + ChangeTag::Insert => { + let new_index = change.new_index()?; + let entry_index = cluster.insert_start.checked_add(new_index)?; + let values = owned_inline_segments(&change, new_lines.get(new_index)?)?; + if segments.insert(entry_index, values).is_some() { + return None; + } + } + ChangeTag::Equal => { + let old_index = change.old_index()?; + let new_index = change.new_index()?; + let old_entry = cluster.delete_start.checked_add(old_index)?; + let new_entry = cluster.insert_start.checked_add(new_index)?; + let old_values = vec![(false, old_lines.get(old_index)?.to_string())]; + let new_values = vec![(false, new_lines.get(new_index)?.to_string())]; + if segments.insert(old_entry, old_values).is_some() + || segments.insert(new_entry, new_values).is_some() + { + return None; + } + } + } + } + if Instant::now() >= deadline { + return None; + } + let expected_len = cluster + .delete_end + .saturating_sub(cluster.delete_start) + .saturating_add(cluster.insert_end.saturating_sub(cluster.insert_start)); + let has_emphasized = segments + .values() + .flatten() + .any(|(emphasized, value)| *emphasized && !value.is_empty()); + let has_unchanged = segments.values().flatten().any(|(emphasized, value)| { + !*emphasized && value.chars().any(|character| !character.is_whitespace()) + }); + (segments.len() == expected_len && has_emphasized && has_unchanged).then_some(segments) +} + +fn visible_inline_segments( + entries: &[DiffHunkEntry], + entry_budget: usize, + deadline: Instant, +) -> InlineSegmentMap { + let visible_end = entry_budget.min(entries.len()); + let mut output = InlineSegmentMap::new(); + for cluster in replacement_clusters(&entries[..visible_end]) { + if Instant::now() >= deadline { + break; + } + let continues_beyond_budget = cluster.insert_end == visible_end + && entries.get(visible_end).is_some_and(|entry| { + matches!( + entry, + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + .. + }) + ) + }); + if continues_beyond_budget { + continue; + } + let Some(cluster_segments) = cluster_inline_segments(entries, cluster, deadline) else { + continue; + }; + output.extend(cluster_segments); + } + output +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiffHunk { + pub(crate) header: String, + pub(crate) entries: Vec, +} + +impl DiffHunk { + pub(crate) fn source_lines(&self) -> impl Iterator { + self.entries.iter().filter_map(|entry| match entry { + DiffHunkEntry::Source(line) => Some(line), + DiffHunkEntry::Metadata(_) => None, + }) + } +} + +/// Owns all parsed text so later tasks can transfer it to a background worker. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ParsedDiff { + pub(crate) destination_path: Option, + pub(crate) has_multiple_files: bool, + pub(crate) has_malformed_hunk: bool, + pub(crate) raw_fallback: Option, + pub(crate) prelude: Vec, + pub(crate) hunks: Vec, + /// Exact parser bytes, including the synthetic `\n` added per source line. + pub(crate) aggregate_source_bytes: usize, + pub(crate) aggregate_source_lines: usize, +} + +impl ParsedDiff { + pub(crate) fn is_structurally_valid(&self) -> bool { + !self.has_malformed_hunk && !self.hunks.is_empty() + } +} + +pub(crate) type RefinedDiffStyles = HashMap; + +/// Worker-facing entry point; callers with a `ParsedDiff` must use this ambiguity guard. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn compute_parsed_diff_file_scoped_styles( + path: &Path, + file_text: &str, + parsed: &ParsedDiff, + theme: SyntaxTheme, + color_level: TerminalColorLevel, +) -> Option { + if parsed.has_multiple_files || !parsed.is_structurally_valid() { + return None; + } + compute_file_scoped_styles(path, file_text, &parsed.hunks, theme, color_level) +} + +fn compute_file_scoped_styles( + path: &Path, + file_text: &str, + hunks: &[DiffHunk], + theme: SyntaxTheme, + color_level: TerminalColorLevel, +) -> Option { + compute_file_scoped_styles_with( + path, + file_text, + hunks, + theme, + color_level, + |highlighter, text| highlighter.highlight_line(text), + ) +} + +#[cfg_attr(not(test), allow(dead_code))] +fn compute_file_scoped_styles_with( + path: &Path, + file_text: &str, + hunks: &[DiffHunk], + theme: SyntaxTheme, + color_level: TerminalColorLevel, + mut highlight_line: impl FnMut(&mut LineHighlighter, &str) -> Option, +) -> Option { + if !content_within_limits(file_text) { + return None; + } + + let mut expected = HashMap::new(); + for line in hunks.iter().flat_map(DiffHunk::source_lines) { + if !matches!(line.kind, DiffLineKind::Context | DiffLineKind::Insert) { + continue; + } + let line_number = line.new_line.filter(|line_number| *line_number > 0)?; + if let Some(existing) = expected.insert(line_number, line.content.as_str()) + && existing != line.content + { + return None; + } + } + + if expected.is_empty() { + return Some(RefinedDiffStyles::new()); + } + + let max_needed = expected.keys().copied().max()?; + let mut highlighter = highlighter_for_path(path, theme, color_level)?; + let mut refined = RefinedDiffStyles::with_capacity(expected.len()); + + for (line_index, source_line) in file_text.split_inclusive('\n').enumerate() { + let line_number = line_index + 1; + if line_number > max_needed { + break; + } + let text = source_line + .strip_suffix('\n') + .map_or(source_line, |text| text.strip_suffix('\r').unwrap_or(text)); + let expected_text = expected.get(&line_number); + if expected_text.is_some_and(|expected_text| *expected_text != text) { + return None; + } + let spans = highlight_line(&mut highlighter, text)?; + if expected_text.is_some() { + refined.insert(line_number, spans); + } + if line_number == max_needed { + break; + } + } + + (refined.len() == expected.len()).then_some(refined) +} + +struct HunkBuilder { + hunk: DiffHunk, + old_next: usize, + new_next: usize, + old_remaining: usize, + new_remaining: usize, + previous_was_source: bool, +} + +impl HunkBuilder { + fn new( + header: String, + old_start: usize, + old_count: usize, + new_start: usize, + new_count: usize, + ) -> Self { + Self { + hunk: DiffHunk { + header, + entries: Vec::new(), + }, + old_next: old_start, + new_next: new_start, + old_remaining: old_count, + new_remaining: new_count, + previous_was_source: false, + } + } + + fn is_complete(&self) -> bool { + self.old_remaining == 0 && self.new_remaining == 0 + } + + fn can_accept(&self, kind: DiffLineKind) -> bool { + match kind { + DiffLineKind::Context => self.old_remaining > 0 && self.new_remaining > 0, + DiffLineKind::Insert => self.new_remaining > 0, + DiffLineKind::Delete => self.old_remaining > 0, + } + } + + fn source_line(&mut self, kind: DiffLineKind, content: String) -> DiffSourceLine { + self.previous_was_source = true; + match kind { + DiffLineKind::Context => { + let line = DiffSourceLine { + kind, + old_line: Some(self.old_next), + new_line: Some(self.new_next), + content, + }; + self.old_next = self.old_next.saturating_add(1); + self.new_next = self.new_next.saturating_add(1); + self.old_remaining = self.old_remaining.saturating_sub(1); + self.new_remaining = self.new_remaining.saturating_sub(1); + line + } + DiffLineKind::Insert => { + let line = DiffSourceLine { + kind, + old_line: None, + new_line: Some(self.new_next), + content, + }; + self.new_next = self.new_next.saturating_add(1); + self.new_remaining = self.new_remaining.saturating_sub(1); + line + } + DiffLineKind::Delete => { + let line = DiffSourceLine { + kind, + old_line: Some(self.old_next), + new_line: None, + content, + }; + self.old_next = self.old_next.saturating_add(1); + self.old_remaining = self.old_remaining.saturating_sub(1); + line + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct DiffRange { + start: usize, + count: usize, + end: usize, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct HunkCoordinates { + old: DiffRange, + new: DiffRange, +} + +enum ParsedHunkHeader { + NotCandidate, + Malformed, + Valid(HunkCoordinates), +} + +enum ParserPhase { + Prelude, + BetweenSections { + follows_section: bool, + }, + PendingNewHeader { + old_path: Option, + follows_section: bool, + }, + AwaitingHunk, + InSection, +} + +impl ParserPhase { + fn take_pending_new_header(&mut self) -> Option<(Option, bool)> { + if !matches!(self, Self::PendingNewHeader { .. }) { + return None; + } + let Self::PendingNewHeader { + old_path, + follows_section, + } = std::mem::replace(self, Self::Prelude) + else { + unreachable!("checked pending header phase"); + }; + Some((old_path, follows_section)) + } +} + +fn is_git_extended_header(line: &str) -> bool { + [ + "index ", + "new file mode ", + "deleted file mode ", + "old mode ", + "new mode ", + "similarity index ", + "dissimilarity index ", + "rename from ", + "rename to ", + "copy from ", + "copy to ", + ] + .iter() + .any(|prefix| line.starts_with(prefix)) +} + +#[derive(Default)] +struct FileSectionState { + old_path_is_null: bool, + new_path_is_null: bool, + previous_old_floor: Option, + previous_new_floor: Option, + hunk_ranges: HashSet, +} + +impl FileSectionState { + fn reset(&mut self, old_path_is_null: bool, new_path_is_null: bool) { + self.old_path_is_null = old_path_is_null; + self.new_path_is_null = new_path_is_null; + self.previous_old_floor = None; + self.previous_new_floor = None; + self.hunk_ranges.clear(); + } + + fn accepts(&mut self, coordinates: HunkCoordinates) -> bool { + let Some(old_floor) = coordinates.old.ordering_floor() else { + return false; + }; + let Some(new_floor) = coordinates.new.ordering_floor() else { + return false; + }; + if (self.old_path_is_null && (coordinates.old.start != 0 || coordinates.old.count != 0)) + || (self.new_path_is_null && (coordinates.new.start != 0 || coordinates.new.count != 0)) + || (self.old_path_is_null && self.new_path_is_null) + || self + .previous_old_floor + .is_some_and(|floor| coordinates.old.start < floor) + || self + .previous_new_floor + .is_some_and(|floor| coordinates.new.start < floor) + || !self.hunk_ranges.insert(coordinates) + { + return false; + } + self.previous_old_floor = Some(old_floor); + self.previous_new_floor = Some(new_floor); + true + } +} + +impl DiffRange { + fn ordering_floor(self) -> Option { + if self.count == 0 { + self.start.checked_add(1) + } else { + Some(self.end) + } + } +} + +fn parse_range(token: &str, marker: char) -> Option { + let coordinates = token.strip_prefix(marker)?; + let mut parts = coordinates.split(','); + let start = parts.next()?.parse().ok()?; + let count = match parts.next() { + Some(count) => count.parse().ok()?, + None => 1, + }; + if parts.next().is_some() { + return None; + } + if count > 0 && start == 0 { + return None; + } + Some(DiffRange { + start, + count, + end: start.checked_add(count)?, + }) +} + +fn parse_hunk_coordinates(header: &str) -> ParsedHunkHeader { + if !header.starts_with("@@") { + return ParsedHunkHeader::NotCandidate; + } + let parsed = (|| { + let mut tokens = header.split_whitespace(); + (tokens.next()? == "@@").then_some(())?; + let old = parse_range(tokens.next()?, '-')?; + let new = parse_range(tokens.next()?, '+')?; + (tokens.next()? == "@@").then_some(())?; + (!(old.count == 0 && new.count == 0)).then_some(HunkCoordinates { old, new }) + })(); + parsed.map_or(ParsedHunkHeader::Malformed, ParsedHunkHeader::Valid) +} + +fn parse_header_path(header_value: &str) -> Option { + let path = header_value + .split_once('\t') + .map_or(header_value, |(path, _)| path); + if path == "/dev/null" { + return None; + } + Some( + path.strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path) + .to_owned(), + ) +} + +fn finish_hunk(current: &mut Option, hunks: &mut Vec) { + if let Some(builder) = current.take() { + hunks.push(builder.hunk); + } +} + +pub(crate) fn parse_unified_diff(diff: &str) -> ParsedDiff { + let mut parsed = ParsedDiff::default(); + let mut current: Option = None; + let mut phase = ParserPhase::Prelude; + let mut section_count = 0usize; + let mut section = FileSectionState::default(); + + for line in diff.lines() { + let hunk_is_active = current + .as_ref() + .is_some_and(|builder| !builder.is_complete()); + if hunk_is_active { + let source = match line.as_bytes().first() { + Some(b' ') => Some((DiffLineKind::Context, &line[1..])), + Some(b'+') => Some((DiffLineKind::Insert, &line[1..])), + Some(b'-') => Some((DiffLineKind::Delete, &line[1..])), + _ => None, + }; + if let Some((kind, content)) = source { + let builder = current.as_mut().expect("active hunk"); + if builder.can_accept(kind) { + let source_line = builder.source_line(kind, content.to_owned()); + parsed.aggregate_source_bytes = parsed + .aggregate_source_bytes + .saturating_add(source_line.content.len().saturating_add(1)); + parsed.aggregate_source_lines = parsed.aggregate_source_lines.saturating_add(1); + builder + .hunk + .entries + .push(DiffHunkEntry::Source(source_line)); + } else { + parsed.has_malformed_hunk = true; + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } + continue; + } + } + + if let Some((old_path, follows_section)) = phase.take_pending_new_header() { + if let Some(header_value) = line.strip_prefix("+++ ") { + let new_path = parse_header_path(header_value); + section_count = section_count.saturating_add(1); + parsed.has_multiple_files = section_count > 1; + parsed.destination_path = new_path.clone().or_else(|| old_path.clone()); + section.reset(old_path.is_none(), new_path.is_none()); + if old_path.is_none() && new_path.is_none() { + parsed.has_malformed_hunk = true; + } + phase = ParserPhase::AwaitingHunk; + if let Some(builder) = current.as_mut() { + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + continue; + } + parsed.has_malformed_hunk = true; + phase = if follows_section { + ParserPhase::InSection + } else { + ParserPhase::Prelude + }; + } + + let hunk_header = parse_hunk_coordinates(line); + if let ParsedHunkHeader::Valid(coordinates) = hunk_header + && !matches!(phase, ParserPhase::BetweenSections { .. }) + { + if matches!(phase, ParserPhase::Prelude) { + section_count = section_count.saturating_add(1); + parsed.has_multiple_files = section_count > 1; + section.reset(false, false); + phase = ParserPhase::InSection; + } + if section.accepts(coordinates) { + if hunk_is_active { + parsed.has_malformed_hunk = true; + } + finish_hunk(&mut current, &mut parsed.hunks); + current = Some(HunkBuilder::new( + line.to_owned(), + coordinates.old.start, + coordinates.old.count, + coordinates.new.start, + coordinates.new.count, + )); + phase = ParserPhase::InSection; + continue; + } + } + if matches!( + hunk_header, + ParsedHunkHeader::Valid(_) | ParsedHunkHeader::Malformed + ) { + parsed.has_malformed_hunk = true; + if let Some(builder) = current.as_mut() { + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + continue; + } + + if matches!(phase, ParserPhase::AwaitingHunk) { + parsed.has_malformed_hunk = true; + } + + if line == "\\ No newline at end of file" { + if let Some(builder) = current.as_mut() + && builder.previous_was_source + { + builder.previous_was_source = false; + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + continue; + } + parsed.has_malformed_hunk = true; + } + + if line.starts_with("diff --git ") { + let can_start_section = matches!(phase, ParserPhase::Prelude) + || (matches!(phase, ParserPhase::InSection) + && current.as_ref().is_some_and(HunkBuilder::is_complete)); + if can_start_section { + phase = ParserPhase::BetweenSections { + follows_section: section_count > 0, + }; + if let Some(builder) = current.as_mut() { + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + continue; + } + if matches!(phase, ParserPhase::BetweenSections { .. }) { + parsed.has_malformed_hunk = true; + if let Some(builder) = current.as_mut() { + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + continue; + } + } + + if matches!(phase, ParserPhase::BetweenSections { .. }) && is_git_extended_header(line) { + if let Some(builder) = current.as_mut() { + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + continue; + } + + if let Some(header_value) = line.strip_prefix("--- ") { + let follows_section = match phase { + ParserPhase::BetweenSections { follows_section } => follows_section, + _ => !matches!(phase, ParserPhase::Prelude), + }; + phase = ParserPhase::PendingNewHeader { + old_path: parse_header_path(header_value), + follows_section, + }; + if let Some(builder) = current.as_mut() { + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + continue; + } + + if matches!(phase, ParserPhase::BetweenSections { .. }) { + parsed.has_malformed_hunk = true; + if let Some(builder) = current.as_mut() { + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + continue; + } + + if line.starts_with("+++ ") { + parsed.has_malformed_hunk = true; + if let Some(builder) = current.as_mut() { + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + continue; + } + + let completed_hunk_has_extra_source = + current.as_ref().is_some_and(HunkBuilder::is_complete) + && matches!(line.as_bytes().first(), Some(b' ' | b'+' | b'-')); + if completed_hunk_has_extra_source { + parsed.has_malformed_hunk = true; + } + + if hunk_is_active { + parsed.has_malformed_hunk = true; + current + .as_mut() + .expect("active hunk") + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + continue; + } + + if let Some(builder) = current.as_mut() { + parsed.has_malformed_hunk = true; + builder + .hunk + .entries + .push(DiffHunkEntry::Metadata(line.to_owned())); + } else { + parsed.prelude.push(line.to_owned()); + } + } + + if matches!( + phase, + ParserPhase::BetweenSections { .. } + | ParserPhase::PendingNewHeader { .. } + | ParserPhase::AwaitingHunk + ) { + parsed.has_malformed_hunk = true; + } + if current + .as_ref() + .is_some_and(|builder| !builder.is_complete()) + { + parsed.has_malformed_hunk = true; + } + finish_hunk(&mut current, &mut parsed.hunks); + if parsed.has_malformed_hunk { + parsed.raw_fallback = Some(diff.to_owned()); + } + parsed +} + +fn source_color(kind: DiffLineKind, theme: &Theme) -> ratatui::style::Color { + match kind { + DiffLineKind::Context => theme.muted, + DiffLineKind::Insert => theme.diff_add, + DiffLineKind::Delete => theme.diff_remove, + } +} + +fn decimal_width(value: usize) -> usize { + value.max(1).ilog10() as usize + 1 +} + +fn parsed_gutter_width(parsed: &ParsedDiff) -> usize { + parsed + .hunks + .iter() + .flat_map(DiffHunk::source_lines) + .flat_map(|line| [line.old_line, line.new_line]) + .flatten() + .map(decimal_width) + .max() + .unwrap_or(1) +} + +fn source_gutter(line: &DiffSourceLine, width: usize) -> String { + let old = line + .old_line + .map_or_else(|| " ".repeat(width), |number| format!("{number:>width$}")); + let new = line + .new_line + .map_or_else(|| " ".repeat(width), |number| format!("{number:>width$}")); + let marker = match line.kind { + DiffLineKind::Context => ' ', + DiffLineKind::Insert => '+', + DiffLineKind::Delete => '-', + }; + format!("{old} {new} {marker} ") +} + +fn unstructured_line_color(line: &str, theme: &Theme) -> ratatui::style::Color { + if line.starts_with('+') && !line.starts_with("+++") { + theme.diff_add + } else if line.starts_with('-') && !line.starts_with("---") { + theme.diff_remove + } else if line.starts_with("@@") { + theme.border + } else { + theme.muted + } +} + +fn refined_spans( + line: &DiffSourceLine, + refined: Option<&RefinedDiffStyles>, +) -> Option { + if line.kind == DiffLineKind::Delete { + return None; + } + let spans = refined?.get(&line.new_line?)?; + let text = spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + (text == line.content).then(|| spans.clone()) +} + +fn row_background(kind: DiffLineKind, theme: &Theme) -> Option { + match (kind, theme.color_level) { + (_, TerminalColorLevel::Monochrome) | (DiffLineKind::Context, _) => None, + (DiffLineKind::Insert, _) => Some(theme.diff_add_bg), + (DiffLineKind::Delete, _) => Some(theme.diff_remove_bg), + } +} + +fn with_background(mut style: Style, background: Option) -> Style { + if let Some(background) = background { + style.bg = Some(background); + } + style +} + +fn emphasized_style(style: Style, kind: DiffLineKind, emphasized: bool, theme: &Theme) -> Style { + if !emphasized { + return with_background(style, row_background(kind, theme)); + } + if theme.color_level == TerminalColorLevel::Monochrome { + return style.add_modifier(ratatui::style::Modifier::BOLD); + } + let background = match kind { + DiffLineKind::Insert => Some(theme.diff_add_emphasis_bg), + DiffLineKind::Delete => Some(theme.diff_remove_emphasis_bg), + DiffLineKind::Context => None, + }; + with_background(style, background) +} + +fn merge_inline_segments( + content: &str, + styled: StyledSourceLine, + inline: &[(bool, String)], + kind: DiffLineKind, + theme: &Theme, +) -> Option { + let styled_text = styled + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let inline_text = inline + .iter() + .map(|(_, value)| value.as_str()) + .collect::(); + if styled_text != content || inline_text != content { + return None; + } + + let mut styled_ranges = Vec::with_capacity(styled.len()); + let mut offset = 0usize; + for span in styled { + let start = offset; + offset = offset.checked_add(span.content.len())?; + styled_ranges.push((start, offset, span)); + } + let mut inline_ranges = Vec::with_capacity(inline.len()); + offset = 0; + for (emphasized, value) in inline { + let start = offset; + offset = offset.checked_add(value.len())?; + inline_ranges.push((start, offset, *emphasized)); + } + if offset != content.len() { + return None; + } + + let mut output = Vec::new(); + let mut styled_index = 0usize; + let mut inline_index = 0usize; + while styled_index < styled_ranges.len() && inline_index < inline_ranges.len() { + let (styled_start, styled_end, styled_span) = &styled_ranges[styled_index]; + let (inline_start, inline_end, emphasized) = inline_ranges[inline_index]; + let start = (*styled_start).max(inline_start); + let end = (*styled_end).min(inline_end); + if start < end { + output.push(Span::styled( + content.get(start..end)?.to_owned(), + emphasized_style(styled_span.style, kind, emphasized, theme), + )); + } + if *styled_end <= inline_end { + styled_index += 1; + } + if inline_end <= *styled_end { + inline_index += 1; + } + } + let reconstructed = output + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + (reconstructed == content).then_some(output) +} + +fn rendered_source_line( + line: &DiffSourceLine, + syntax_spans: Option, + refined: Option<&RefinedDiffStyles>, + theme: &Theme, + gutter_width: usize, + inline: Option<&[(bool, String)]>, +) -> Line<'static> { + let fallback_color = source_color(line.kind, theme); + let background = row_background(line.kind, theme); + let mut spans = vec![Span::styled( + source_gutter(line, gutter_width), + with_background(Style::default().fg(fallback_color), background), + )]; + let content_spans = refined_spans(line, refined) + .or(syntax_spans) + .unwrap_or_else(|| { + vec![Span::styled( + line.content.clone(), + Style::default().fg(fallback_color), + )] + }); + let content_spans = inline + .and_then(|inline| { + merge_inline_segments( + &line.content, + content_spans.clone(), + inline, + line.kind, + theme, + ) + }) + .unwrap_or_else(|| { + content_spans + .into_iter() + .map(|span| Span::styled(span.content, with_background(span.style, background))) + .collect() + }); + spans.extend(content_spans); + Line::from(spans) +} + +#[derive(Clone, Copy)] +enum DiffSide { + Old, + New, +} + +fn rendered_metadata_line(line: &str, theme: &Theme) -> Line<'static> { + Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(theme.muted), + )) +} + +fn hunk_scope(header: &str) -> &str { + header + .strip_prefix("@@") + .and_then(|rest| rest.split_once("@@")) + .map_or("", |(_, suffix)| suffix.trim()) +} + +fn rendered_hunk_separator(hunk: &DiffHunk, gutter_width: usize, theme: &Theme) -> Line<'static> { + let gutter = format!("{:>width$} {:>width$} ", "⋮", "⋮", width = gutter_width); + Line::from(vec![ + Span::styled(gutter, Style::default().fg(theme.border)), + Span::styled( + hunk_scope(&hunk.header).to_owned(), + Style::default().fg(theme.muted), + ), + ]) +} + +fn render_hunk_fallback( + hunk: &DiffHunk, + entry_budget: usize, + theme: &Theme, + gutter_width: usize, + inline_deadline: Option, +) -> Vec> { + let inline_segments = inline_deadline.map_or_else(InlineSegmentMap::new, |deadline| { + visible_inline_segments(&hunk.entries, entry_budget, deadline) + }); + hunk.entries + .iter() + .take(entry_budget) + .enumerate() + .map(|(entry_index, entry)| match entry { + DiffHunkEntry::Source(line) => rendered_source_line( + line, + None, + None, + theme, + gutter_width, + inline_segments.get(&entry_index).map(Vec::as_slice), + ), + DiffHunkEntry::Metadata(line) => rendered_metadata_line(line, theme), + }) + .collect() +} + +fn render_hunk_with( + hunk: &DiffHunk, + entry_budget: usize, + theme: &Theme, + refined: Option<&RefinedDiffStyles>, + gutter_width: usize, + inline_deadline: Instant, + mut highlight: impl FnMut(DiffSide, &str) -> Option, +) -> Vec> { + let visible_entries = hunk.entries.iter().take(entry_budget).collect::>(); + let inline_segments = visible_inline_segments(&hunk.entries, entry_budget, inline_deadline); + let mut highlighted = Vec::with_capacity(visible_entries.len()); + for entry in &visible_entries { + let DiffHunkEntry::Source(line) = entry else { + continue; + }; + let spans = match line.kind { + DiffLineKind::Context => { + if highlight(DiffSide::Old, &line.content).is_none() { + return render_hunk_fallback( + hunk, + entry_budget, + theme, + gutter_width, + Some(inline_deadline), + ); + } + highlight(DiffSide::New, &line.content) + } + DiffLineKind::Insert => highlight(DiffSide::New, &line.content), + DiffLineKind::Delete => highlight(DiffSide::Old, &line.content), + }; + let Some(spans) = spans else { + return render_hunk_fallback( + hunk, + entry_budget, + theme, + gutter_width, + Some(inline_deadline), + ); + }; + highlighted.push(spans); + } + + let mut highlighted = highlighted.into_iter(); + visible_entries + .into_iter() + .enumerate() + .map(|(entry_index, entry)| match entry { + DiffHunkEntry::Source(line) => rendered_source_line( + line, + Some(highlighted.next().expect("one style per source line")), + refined, + theme, + gutter_width, + inline_segments.get(&entry_index).map(Vec::as_slice), + ), + DiffHunkEntry::Metadata(line) => rendered_metadata_line(line, theme), + }) + .collect() +} + +fn parsed_line_count(parsed: &ParsedDiff) -> usize { + parsed.prelude.len() + + parsed + .hunks + .iter() + .map(|hunk| 1usize.saturating_add(hunk.entries.len())) + .sum::() +} + +fn render_raw_diff(raw_diff: &str, theme: &Theme) -> Vec> { + let mut rendered = raw_diff + .lines() + .take(MAX_RENDERED_DIFF_LINES) + .map(|line| { + Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(unstructured_line_color(line, theme)), + )) + }) + .collect::>(); + if raw_diff.lines().count() > MAX_RENDERED_DIFF_LINES { + rendered.push(Line::from(Span::styled( + TRUNCATION_MARKER, + Style::default().fg(theme.muted), + ))); + } + rendered +} + +fn syntax_is_eligible(parsed: &ParsedDiff) -> bool { + !parsed.has_multiple_files + && parsed.is_structurally_valid() + && parsed.aggregate_source_bytes <= MAX_HIGHLIGHT_BYTES + && parsed.aggregate_source_lines <= MAX_HIGHLIGHT_LINES + && parsed + .hunks + .iter() + .flat_map(DiffHunk::source_lines) + .all(|line| content_within_limits(&line.content)) +} + +fn render_parsed_diff_with( + parsed: &ParsedDiff, + theme: &Theme, + mut render_hunk: impl FnMut(&DiffHunk, usize, usize) -> Vec>, +) -> Vec> { + let total_lines = parsed_line_count(parsed); + let gutter_width = parsed_gutter_width(parsed); + let mut remaining = MAX_RENDERED_DIFF_LINES; + let mut rendered = Vec::with_capacity(total_lines.min(MAX_RENDERED_DIFF_LINES) + 1); + + for line in &parsed.prelude { + if remaining == 0 { + break; + } + rendered.push(Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(unstructured_line_color(line, theme)), + ))); + remaining -= 1; + } + + if remaining > 0 { + 'hunks: for hunk in &parsed.hunks { + if remaining == 0 { + break; + } + rendered.push(rendered_hunk_separator(hunk, gutter_width, theme)); + remaining -= 1; + if remaining == 0 { + break 'hunks; + } + + let entry_budget = remaining.min(hunk.entries.len()); + let hunk_lines = render_hunk(hunk, entry_budget, gutter_width); + assert_eq!( + hunk_lines.len(), + entry_budget, + "bounded hunk renderer must preserve every admitted entry" + ); + rendered.extend(hunk_lines); + remaining -= entry_budget; + if remaining == 0 { + break 'hunks; + } + } + } + + if total_lines > MAX_RENDERED_DIFF_LINES { + rendered.push(Line::from(Span::styled( + TRUNCATION_MARKER, + Style::default().fg(theme.muted), + ))); + } + rendered +} + +pub(crate) fn render_parsed_diff( + parsed: &ParsedDiff, + theme: &Theme, + refined: Option<&RefinedDiffStyles>, +) -> Vec> { + render_parsed_diff_at(parsed, theme, refined, None) +} + +fn render_parsed_diff_at( + parsed: &ParsedDiff, + theme: &Theme, + refined: Option<&RefinedDiffStyles>, + fixed_inline_deadline: Option, +) -> Vec> { + if parsed.has_malformed_hunk { + debug_assert!( + parsed.raw_fallback.is_some(), + "malformed parsed diffs must retain their raw fallback" + ); + return parsed.raw_fallback.as_deref().map_or_else( + || { + render_parsed_diff_with(parsed, theme, |hunk, entry_budget, gutter_width| { + render_hunk_fallback(hunk, entry_budget, theme, gutter_width, None) + }) + }, + |raw_diff| render_raw_diff(raw_diff, theme), + ); + } + + let syntax_eligible = syntax_is_eligible(parsed); + let syntax_path = syntax_eligible + .then_some(parsed.destination_path.as_deref()) + .flatten(); + let refined = syntax_eligible.then_some(refined).flatten(); + let mut inline_deadline = fixed_inline_deadline; + + render_parsed_diff_with(parsed, theme, |hunk, entry_budget, gutter_width| { + if inline_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return render_hunk_fallback(hunk, entry_budget, theme, gutter_width, None); + } + syntax_path + .and_then(|path| { + let mut old = + highlighter_for_path(Path::new(path), theme.syntax_theme, theme.color_level)?; + let mut new = + highlighter_for_path(Path::new(path), theme.syntax_theme, theme.color_level)?; + let deadline = + *inline_deadline.get_or_insert_with(|| Instant::now() + INLINE_DIFF_DEADLINE); + Some(render_hunk_with( + hunk, + entry_budget, + theme, + refined, + gutter_width, + deadline, + |side, content| match side { + DiffSide::Old => old.highlight_line(content), + DiffSide::New => new.highlight_line(content), + }, + )) + }) + .unwrap_or_else(|| { + let deadline = + *inline_deadline.get_or_insert_with(|| Instant::now() + INLINE_DIFF_DEADLINE); + render_hunk_fallback(hunk, entry_budget, theme, gutter_width, Some(deadline)) + }) + }) +} + +#[cfg(test)] +fn render_parsed_diff_with_inline_deadline( + parsed: &ParsedDiff, + theme: &Theme, + refined: Option<&RefinedDiffStyles>, + deadline: Instant, +) -> Vec> { + render_parsed_diff_at(parsed, theme, refined, Some(deadline)) +} + +pub(crate) fn render_unified_diff( + diff: &str, + theme: &Theme, + refined: Option<&RefinedDiffStyles>, +) -> Vec> { + render_parsed_diff(&parse_unified_diff(diff), theme, refined) +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + use std::path::Path; + use std::time::{Duration, Instant}; + + use orca_core::config::ThemeName; + use orca_core::tool_types::FileChangePreview; + use ratatui::style::{Color, Modifier, Style}; + use ratatui::text::{Line, Span}; + + use super::{ + DiffHunk, DiffHunkEntry, DiffLineKind, DiffSourceLine, INLINE_DIFF_DEADLINE, + RefinedDiffStyles, ReplacementCluster, cluster_inline_segments, compute_file_scoped_styles, + compute_parsed_diff_file_scoped_styles, parse_unified_diff, render_parsed_diff, + render_parsed_diff_with_inline_deadline, render_unified_diff, replacement_clusters, + visible_inline_segments, + }; + use crate::syntax_highlight::{ + MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINE_BYTES, MAX_HIGHLIGHT_LINES, StyledSourceLine, + highlighter_for_path, + }; + use crate::terminal_capabilities::{TerminalBackground, TerminalColorLevel, TerminalProfile}; + use crate::theme::Theme; + + const RUST_DIFF: &str = "\ +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,2 +1,2 @@ +-fn old() { let value = \"old\"; } ++fn new() { let value = \"new\"; } + context(); +"; + + const INLINE_DIFF: &str = "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +1 @@ +-let colour = \"red apple\"; ++let color = \"green apple\"; +"; + + fn dark_theme() -> Theme { + Theme::named(ThemeName::Dark) + } + + fn color_fits(level: TerminalColorLevel, color: Option) -> bool { + match level { + TerminalColorLevel::TrueColor => true, + TerminalColorLevel::Ansi256 => !matches!(color, Some(Color::Rgb(..))), + TerminalColorLevel::Ansi16 => { + !matches!(color, Some(Color::Rgb(..) | Color::Indexed(_))) + } + TerminalColorLevel::Monochrome => color.is_none() || color == Some(Color::Reset), + } + } + + #[test] + fn parsed_diff_styles_obey_terminal_color_level() { + let parsed = parse_unified_diff(RUST_DIFF); + + for level in [ + TerminalColorLevel::TrueColor, + TerminalColorLevel::Ansi256, + TerminalColorLevel::Ansi16, + TerminalColorLevel::Monochrome, + ] { + let theme = Theme::resolve( + ThemeName::Dark, + TerminalProfile { + background: TerminalBackground::Dark, + color_level: level, + }, + ); + let rendered = render_parsed_diff(&parsed, &theme, None); + + assert!(rendered.iter().flat_map(|line| &line.spans).all(|span| { + color_fits(level, span.style.fg) && color_fits(level, span.style.bg) + })); + } + } + + #[test] + fn changed_rows_apply_exact_dark_backgrounds_to_gutter_and_content() { + let theme = dark_theme(); + let lines = render_unified_diff(RUST_DIFF, &theme, None); + let deletion = find_rendered_line(&lines, "-fn old"); + let insertion = find_rendered_line(&lines, "+fn new"); + + assert_eq!(deletion.spans[0].style.bg, Some(theme.diff_remove_bg)); + assert_eq!(insertion.spans[0].style.bg, Some(theme.diff_add_bg)); + assert!(deletion.spans.iter().skip(1).all(|span| matches!( + span.style.bg, + Some(background) + if background == theme.diff_remove_bg + || background == theme.diff_remove_emphasis_bg + ))); + assert!(insertion.spans.iter().skip(1).all(|span| matches!( + span.style.bg, + Some(background) + if background == theme.diff_add_bg + || background == theme.diff_add_emphasis_bg + ))); + } + + #[test] + fn diff_backgrounds_preserve_syntax_and_refined_foregrounds() { + let theme = dark_theme(); + let overlay = Color::Rgb(1, 2, 3); + let refined = RefinedDiffStyles::from([( + 1, + vec![Span::styled( + "fn new() { let value = \"new\"; }", + Style::default().fg(overlay).add_modifier(Modifier::ITALIC), + )], + )]); + let lines = render_unified_diff(RUST_DIFF, &theme, Some(&refined)); + let insertion = find_rendered_line(&lines, "+fn new"); + let refined_span = insertion + .spans + .iter() + .find(|span| span.style.fg == Some(overlay)) + .expect("refined foreground"); + + assert_eq!(refined_span.style.bg, Some(theme.diff_add_bg)); + assert!(refined_span.style.add_modifier.contains(Modifier::ITALIC)); + } + + #[test] + fn monochrome_changed_rows_use_markers_without_backgrounds() { + let theme = Theme::resolve( + ThemeName::Dark, + TerminalProfile { + background: TerminalBackground::Dark, + color_level: TerminalColorLevel::Monochrome, + }, + ); + let lines = render_unified_diff(RUST_DIFF, &theme, None); + let deletion = find_rendered_line(&lines, "-fn old"); + let insertion = find_rendered_line(&lines, "+fn new"); + + assert!(rendered_text(deletion).contains("- ")); + assert!(rendered_text(insertion).contains("+ ")); + assert!(deletion.spans.iter().all(|span| span.style.bg.is_none())); + assert!(insertion.spans.iter().all(|span| span.style.bg.is_none())); + } + + fn rendered_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn span_text<'a>(line: &'a Line<'_>, index: usize) -> &'a str { + line.spans[index].content.as_ref() + } + + fn find_rendered_line<'a>(lines: &'a [Line<'static>], needle: &str) -> &'a Line<'static> { + let (marker, source_needle) = needle + .strip_prefix('+') + .map(|source| (Some('+'), source)) + .or_else(|| needle.strip_prefix('-').map(|source| (Some('-'), source))) + .unwrap_or((None, needle)); + let marker_matches = |line: &Line<'static>| { + marker.is_none_or(|marker| { + line.spans + .first() + .is_some_and(|span| span.content.ends_with(&format!("{marker} "))) + }) + }; + if marker.is_some() + && let Some(line) = lines.iter().find(|line| { + marker_matches(line) + && line.spans[1..] + .iter() + .map(|span| span.content.as_ref()) + .collect::() + == source_needle + }) + { + return line; + } + lines + .iter() + .find(|line| rendered_text(line).contains(source_needle) && marker_matches(line)) + .unwrap_or_else(|| panic!("rendered line containing {needle:?}")) + } + + fn highlight_sequence(path: &str, source: &[&str], theme: &Theme) -> Vec { + let mut highlighter = highlighter_for_path( + Path::new(path), + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .expect("known syntax"); + source + .iter() + .map(|line| highlighter.highlight_line(line).expect("highlighted line")) + .collect() + } + + fn without_backgrounds(spans: &[Span<'_>]) -> StyledSourceLine { + let mut output: StyledSourceLine = Vec::new(); + for span in spans { + let mut style = span.style; + style.bg = None; + if let Some(previous) = output.last_mut() + && previous.style == style + { + previous.content.to_mut().push_str(span.content.as_ref()); + continue; + } + output.push(Span::styled(span.content.to_string(), style)); + } + output + } + + fn assert_same_styled_text(left: &[Span<'_>], right: &[Span<'_>]) { + assert_eq!(without_backgrounds(left), without_backgrounds(right)); + } + + fn assert_different_styled_text(left: &[Span<'_>], right: &[Span<'_>]) { + assert_ne!(without_backgrounds(left), without_backgrounds(right)); + } + + fn assert_plain_source( + line: &Line<'_>, + prefix: &str, + content: &str, + color: ratatui::style::Color, + ) { + assert!(line.spans.len() >= 2); + let marker = prefix.trim(); + if marker.is_empty() { + assert!( + line.spans[0].content.ends_with(" "), + "context gutter: {:?}", + line.spans[0].content + ); + } else { + assert!( + line.spans[0].content.ends_with(&format!("{marker} ")), + "changed gutter: {:?}", + line.spans[0].content + ); + } + assert_eq!(line.spans[0].style.fg, Some(color)); + assert_eq!( + line.spans[1..] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + content + ); + assert!( + line.spans[1..] + .iter() + .all(|span| span.style.fg == Some(color)) + ); + } + + fn assert_malformed_raw_fallback(diff: &str) { + let theme = dark_theme(); + let overlay_color = Color::Rgb(1, 2, 3); + let mut refined = RefinedDiffStyles::new(); + refined.insert( + 1, + vec![Span::styled( + "refined overlay must not render", + Style::default().fg(overlay_color), + )], + ); + + let parsed = parse_unified_diff(diff); + + assert!( + parsed.has_malformed_hunk, + "fixture must be malformed:\n{diff}" + ); + assert!(!parsed.is_structurally_valid()); + assert_eq!(parsed.raw_fallback.as_deref(), Some(diff)); + assert!( + compute_parsed_diff_file_scoped_styles( + Path::new("value.rs"), + "let value = 2;\n", + &parsed, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + let rendered = render_parsed_diff(&parsed, &theme, Some(&refined)); + assert_eq!(rendered.len(), diff.lines().count()); + for (raw_line, rendered_line) in diff.lines().zip(&rendered) { + assert_eq!(rendered_text(rendered_line), format!(" {raw_line}")); + assert_eq!( + rendered_line.spans.len(), + 1, + "raw fallback must not segment syntax tokens: {raw_line:?}" + ); + let expected_color = if raw_line.starts_with('+') && !raw_line.starts_with("+++") { + theme.diff_add + } else if raw_line.starts_with('-') && !raw_line.starts_with("---") { + theme.diff_remove + } else if raw_line.starts_with("@@") { + theme.border + } else { + theme.muted + }; + assert_eq!(rendered_line.spans[0].style.fg, Some(expected_color)); + assert_ne!(rendered_line.spans[0].style.fg, Some(overlay_color)); + } + } + + fn padded_rust_line(len: usize) -> String { + let start = "let value = \"syntax\";"; + format!("{start}{}", " ".repeat(len - start.len())) + } + + #[test] + fn parser_tracks_destination_and_old_new_line_numbers() { + let parsed = parse_unified_diff(RUST_DIFF); + + assert_eq!(parsed.destination_path.as_deref(), Some("src/main.rs")); + assert!( + parsed.raw_fallback.is_none(), + "valid parsed diffs must not duplicate the original diff text" + ); + assert_eq!(parsed.hunks.len(), 1); + let source = parsed.hunks[0].source_lines().collect::>(); + assert_eq!( + source + .iter() + .map(|line| ( + line.kind, + line.old_line, + line.new_line, + line.content.as_str() + )) + .collect::>(), + vec![ + ( + DiffLineKind::Delete, + Some(1), + None, + "fn old() { let value = \"old\"; }", + ), + ( + DiffLineKind::Insert, + None, + Some(1), + "fn new() { let value = \"new\"; }", + ), + (DiffLineKind::Context, Some(2), Some(2), "context();",), + ] + ); + assert_eq!( + parsed.aggregate_source_bytes, + source + .iter() + .map(|line| line.content.len().saturating_add(1)) + .sum::() + ); + assert_eq!(parsed.aggregate_source_lines, 3); + } + + #[test] + fn generated_context_that_looks_like_hunk_header_stays_source() { + let preview = orca_tools::file_admission::build_file_change_preview( + "value.rs", + Some("before\n@@ -1 +1 @@\nold\n"), + Some("before\n@@ -1 +1 @@\nnew\n"), + ); + let FileChangePreview::UnifiedDiff { text: diff, .. } = preview else { + panic!("small text diff should produce unified diff"); + }; + assert!( + diff.lines().any(|line| line == " @@ -1 +1 @@"), + "fixture must contain the hunk-like context line:\n{diff}" + ); + + let parsed = parse_unified_diff(&diff); + + assert_eq!(parsed.hunks.len(), 1, "generated diff:\n{diff}"); + assert_eq!( + parsed.hunks[0] + .source_lines() + .map(|line| ( + line.kind, + line.old_line, + line.new_line, + line.content.as_str() + )) + .collect::>(), + vec![ + (DiffLineKind::Context, Some(1), Some(1), "before"), + (DiffLineKind::Context, Some(2), Some(2), "@@ -1 +1 @@"), + (DiffLineKind::Delete, Some(3), None, "old"), + (DiffLineKind::Insert, None, Some(3), "new"), + ] + ); + } + + #[test] + fn replacement_clusters_include_only_adjacent_delete_then_insert_blocks() { + let entries = vec![ + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Delete, + old_line: Some(1), + new_line: None, + content: "old one".to_string(), + }), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Delete, + old_line: Some(2), + new_line: None, + content: "old two".to_string(), + }), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + old_line: None, + new_line: Some(1), + content: "new one".to_string(), + }), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + old_line: None, + new_line: Some(2), + content: "new two".to_string(), + }), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Context, + old_line: Some(3), + new_line: Some(3), + content: "context".to_string(), + }), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Delete, + old_line: Some(4), + new_line: None, + content: "old three".to_string(), + }), + DiffHunkEntry::Metadata("\\ No newline at end of file".to_string()), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + old_line: None, + new_line: Some(4), + content: "new three".to_string(), + }), + ]; + + assert_eq!( + replacement_clusters(&entries), + vec![ReplacementCluster { + delete_start: 0, + delete_end: 2, + insert_start: 2, + insert_end: 4, + }] + ); + } + + #[test] + fn adjacent_replacement_emphasizes_only_changed_inline_tokens() { + let theme = dark_theme(); + let lines = render_unified_diff(INLINE_DIFF, &theme, None); + let deletion = find_rendered_line(&lines, "-let colour"); + let insertion = find_rendered_line(&lines, "+let color"); + + assert!(deletion.spans.iter().any(|span| { + span.content.contains("colour") && span.style.bg == Some(theme.diff_remove_emphasis_bg) + })); + assert!(insertion.spans.iter().any(|span| { + span.content.contains("color") && span.style.bg == Some(theme.diff_add_emphasis_bg) + })); + assert!(deletion.spans.iter().any(|span| { + span.content.contains("apple") && span.style.bg == Some(theme.diff_remove_bg) + })); + assert!(insertion.spans.iter().any(|span| { + span.content.contains("apple") && span.style.bg == Some(theme.diff_add_bg) + })); + assert_eq!( + deletion.spans[1..] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "let colour = \"red apple\";" + ); + assert_eq!( + insertion.spans[1..] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "let color = \"green apple\";" + ); + } + + #[test] + fn multiline_replacement_maps_inline_segments_to_their_original_rows() { + let diff = "\ +--- a/value.txt ++++ b/value.txt +@@ -10,2 +20,2 @@ +-first red apple +-second blue berry ++first green apple ++second gold berry +"; + let theme = dark_theme(); + let lines = render_unified_diff(diff, &theme, None); + let first_old = find_rendered_line(&lines, "-first red apple"); + let second_old = find_rendered_line(&lines, "-second blue berry"); + let first_new = find_rendered_line(&lines, "+first green apple"); + let second_new = find_rendered_line(&lines, "+second gold berry"); + + for (line, changed, background) in [ + (first_old, "red", theme.diff_remove_emphasis_bg), + (second_old, "blue", theme.diff_remove_emphasis_bg), + (first_new, "green", theme.diff_add_emphasis_bg), + (second_new, "gold", theme.diff_add_emphasis_bg), + ] { + assert!(line.spans.iter().any(|span| { + span.content.contains(changed) && span.style.bg == Some(background) + })); + } + assert_eq!(span_text(first_old, 0), "10 - "); + assert_eq!(span_text(second_old, 0), "11 - "); + assert_eq!(span_text(first_new, 0), " 20 + "); + assert_eq!(span_text(second_new, 0), " 21 + "); + } + + #[test] + fn unknown_extension_still_receives_inline_emphasis_over_fallback_foreground() { + let diff = "\ +--- a/value.unknown ++++ b/value.unknown +@@ -1 +1 @@ +-let colour = \"red apple\"; ++let color = \"green apple\"; +"; + let theme = dark_theme(); + let lines = render_unified_diff(diff, &theme, None); + let deletion = find_rendered_line(&lines, "-let colour"); + let insertion = find_rendered_line(&lines, "+let color"); + + assert!(deletion.spans.iter().any(|span| { + span.content.contains("colour") + && span.style.fg == Some(theme.diff_remove) + && span.style.bg == Some(theme.diff_remove_emphasis_bg) + })); + assert!(insertion.spans.iter().any(|span| { + span.content.contains("color") + && span.style.fg == Some(theme.diff_add) + && span.style.bg == Some(theme.diff_add_emphasis_bg) + })); + } + + #[test] + fn nonadjacent_replacement_entries_do_not_receive_inline_emphasis() { + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -1,2 +1,2 @@ +-let colour = \"red apple\"; + context ++let color = \"green apple\"; +"; + let theme = dark_theme(); + let lines = render_unified_diff(diff, &theme, None); + let deletion = find_rendered_line(&lines, "-let colour"); + let insertion = find_rendered_line(&lines, "+let color"); + + assert!( + deletion + .spans + .iter() + .all(|span| { span.style.bg != Some(theme.diff_remove_emphasis_bg) }) + ); + assert!( + insertion + .spans + .iter() + .all(|span| { span.style.bg != Some(theme.diff_add_emphasis_bg) }) + ); + } + + #[test] + fn low_similarity_replacement_keeps_only_whole_row_backgrounds() { + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +1 @@ +-alpha beta gamma ++one two three +"; + let theme = dark_theme(); + let lines = render_unified_diff(diff, &theme, None); + let deletion = find_rendered_line(&lines, "-alpha"); + let insertion = find_rendered_line(&lines, "+one"); + + assert!( + deletion + .spans + .iter() + .all(|span| { span.style.bg != Some(theme.diff_remove_emphasis_bg) }) + ); + assert!( + insertion + .spans + .iter() + .all(|span| { span.style.bg != Some(theme.diff_add_emphasis_bg) }) + ); + } + + #[test] + fn inline_emphasis_preserves_unicode_and_combining_text_exactly() { + let diff = "\ +--- a/value.txt ++++ b/value.txt +@@ -1 +1 @@ +-cafe\u{301} 👍🏽 red ++cafe\u{301} 👍🏽 green +"; + let theme = dark_theme(); + let lines = render_unified_diff(diff, &theme, None); + let deletion = find_rendered_line(&lines, "-cafe"); + let insertion = find_rendered_line(&lines, "+cafe"); + + assert_eq!( + deletion.spans[1..] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "cafe\u{301} 👍🏽 red" + ); + assert_eq!( + insertion.spans[1..] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "cafe\u{301} 👍🏽 green" + ); + assert!(insertion.spans.iter().any(|span| { + span.content.contains("green") && span.style.bg == Some(theme.diff_add_emphasis_bg) + })); + } + + #[test] + fn inline_emphasis_preserves_refined_foreground_and_modifiers() { + let theme = dark_theme(); + let overlay = Color::Rgb(1, 2, 3); + let refined = RefinedDiffStyles::from([( + 1, + vec![Span::styled( + "let color = \"green apple\";", + Style::default().fg(overlay).add_modifier(Modifier::ITALIC), + )], + )]); + let lines = render_unified_diff(INLINE_DIFF, &theme, Some(&refined)); + let insertion = find_rendered_line(&lines, "+let color"); + let emphasized = insertion + .spans + .iter() + .find(|span| span.style.bg == Some(theme.diff_add_emphasis_bg)) + .expect("emphasized refined span"); + + assert_eq!(emphasized.style.fg, Some(overlay)); + assert!(emphasized.style.add_modifier.contains(Modifier::ITALIC)); + } + + #[test] + fn monochrome_inline_emphasis_uses_bold_without_backgrounds() { + let theme = Theme::resolve( + ThemeName::Dark, + TerminalProfile { + background: TerminalBackground::Dark, + color_level: TerminalColorLevel::Monochrome, + }, + ); + let lines = render_unified_diff(INLINE_DIFF, &theme, None); + let deletion = find_rendered_line(&lines, "-let colour"); + let insertion = find_rendered_line(&lines, "+let color"); + + assert!(deletion.spans.iter().all(|span| span.style.bg.is_none())); + assert!(insertion.spans.iter().all(|span| span.style.bg.is_none())); + assert!(deletion.spans.iter().any(|span| { + span.content.contains("colour") && span.style.add_modifier.contains(Modifier::BOLD) + })); + assert!(insertion.spans.iter().any(|span| { + span.content.contains("color") && span.style.add_modifier.contains(Modifier::BOLD) + })); + } + + #[test] + fn truncated_replacement_cluster_is_not_partially_emphasized() { + let inserted = (1..77) + .map(|index| format!("+new unrelated {index}\n")) + .collect::(); + let diff = format!( + "--- a/value.txt\n+++ b/value.txt\n@@ -1 +1,77 @@\n-old value\n+new value\n{inserted}" + ); + let theme = dark_theme(); + let lines = render_unified_diff(&diff, &theme, None); + let visible_insertion = find_rendered_line(&lines, "+new value"); + + assert_eq!(lines.len(), 81); + assert!( + visible_insertion + .spans + .iter() + .all(|span| { span.style.bg != Some(theme.diff_add_emphasis_bg) }) + ); + } + + #[test] + fn visible_inline_segments_rejects_a_cluster_cut_by_entry_budget() { + let entries = vec![ + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Delete, + old_line: Some(1), + new_line: None, + content: "let colour = \"red apple\";".to_string(), + }), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + old_line: None, + new_line: Some(1), + content: "let color = \"green apple\";".to_string(), + }), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + old_line: None, + new_line: Some(2), + content: "another inserted line".to_string(), + }), + ]; + + assert!( + visible_inline_segments(&entries, 2, Instant::now() + INLINE_DIFF_DEADLINE,).is_empty(), + "a partially visible replacement cluster must not be refined" + ); + } + + #[test] + fn expired_inline_deadline_rejects_cluster_results() { + let parsed = parse_unified_diff(INLINE_DIFF); + let cluster = replacement_clusters(&parsed.hunks[0].entries)[0]; + + assert!( + cluster_inline_segments( + &parsed.hunks[0].entries, + cluster, + Instant::now() - Duration::from_millis(1), + ) + .is_none(), + "cooperative timeout results must not be rendered after expiry" + ); + } + + #[test] + fn one_expired_inline_deadline_suppresses_emphasis_across_all_hunks() { + let diff = "\ +--- a/value.txt ++++ b/value.txt +@@ -1 +1 @@ +-first red apple ++first green apple +@@ -10 +10 @@ +-second blue berry ++second gold berry +"; + let theme = dark_theme(); + let parsed = parse_unified_diff(diff); + let lines = render_parsed_diff_with_inline_deadline( + &parsed, + &theme, + None, + Instant::now() - Duration::from_millis(1), + ); + + assert!(lines.iter().flat_map(|line| &line.spans).all(|span| { + span.style.bg != Some(theme.diff_add_emphasis_bg) + && span.style.bg != Some(theme.diff_remove_emphasis_bg) + })); + } + + #[test] + fn over_limit_replacement_line_skips_inline_emphasis() { + let old = format!("prefix {} old", "x".repeat(MAX_HIGHLIGHT_LINE_BYTES)); + let new = format!("prefix {} new", "x".repeat(MAX_HIGHLIGHT_LINE_BYTES)); + let diff = format!("--- a/value.txt\n+++ b/value.txt\n@@ -1 +1 @@\n-{old}\n+{new}\n"); + let theme = dark_theme(); + let lines = render_unified_diff(&diff, &theme, None); + let deletion = find_rendered_line(&lines, "-prefix"); + let insertion = find_rendered_line(&lines, "+prefix"); + + assert!( + deletion + .spans + .iter() + .all(|span| { span.style.bg != Some(theme.diff_remove_emphasis_bg) }) + ); + assert!( + insertion + .spans + .iter() + .all(|span| { span.style.bg != Some(theme.diff_add_emphasis_bg) }) + ); + } + + #[test] + fn structured_diff_uses_one_right_aligned_dual_line_number_gutter() { + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -9,2 +99,2 @@ fn value() + old +-before ++after +"; + let theme = dark_theme(); + let lines = render_unified_diff(diff, &theme, None); + + let context = find_rendered_line(&lines, "old"); + let deletion = find_rendered_line(&lines, "before"); + let insertion = find_rendered_line(&lines, "after"); + + assert_eq!(span_text(context, 0), " 9 99 "); + assert_eq!(span_text(deletion, 0), " 10 - "); + assert_eq!(span_text(insertion, 0), " 100 + "); + assert_eq!(context.spans[0].style.fg, Some(theme.muted)); + assert_eq!(deletion.spans[0].style.fg, Some(theme.diff_remove)); + assert_eq!(insertion.spans[0].style.fg, Some(theme.diff_add)); + } + + #[test] + fn multiple_hunks_share_the_largest_gutter_width() { + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -9 +9 @@ first +-old nine ++new nine +@@ -123 +123 @@ second +-old one twenty three ++new one twenty three +"; + let lines = render_unified_diff(diff, &dark_theme(), None); + + assert_eq!( + span_text(find_rendered_line(&lines, "old nine"), 0), + " 9 - " + ); + assert_eq!( + span_text(find_rendered_line(&lines, "new nine"), 0), + " 9 + " + ); + assert_eq!( + span_text(find_rendered_line(&lines, "old one twenty three"), 0), + "123 - " + ); + assert_eq!( + span_text(find_rendered_line(&lines, "new one twenty three"), 0), + " 123 + " + ); + } + + #[test] + fn hunk_separator_hides_coordinates_and_keeps_scope_context() { + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -12 +34 @@ impl Value +-old ++new +"; + let lines = render_unified_diff(diff, &dark_theme(), None); + let separator = &lines[2]; + let text = rendered_text(separator); + + assert_eq!(text, " ⋮ ⋮ impl Value"); + assert!(!text.contains("@@")); + assert!(!text.contains("-12")); + assert!(!text.contains("+34")); + } + + #[test] + fn hunk_separator_without_scope_contains_only_the_dual_ellipsis_gutter() { + let diff = "--- a/v\n+++ b/v\n@@ -1 +1 @@\n-old\n+new\n"; + let lines = render_unified_diff(diff, &dark_theme(), None); + + assert_eq!(rendered_text(&lines[2]), "⋮ ⋮ "); + } + + #[test] + fn malformed_hunk_candidate_forces_first_paint_raw_fallback() { + assert_malformed_raw_fallback( + "\ +--- a/value.rs ++++ b/value.rs +@@ malformed coordinates @@ +@@ -1 +1 @@ valid function context +-let old = 1; ++let value = 2; +", + ); + } + + #[test] + fn content_between_header_pair_and_first_hunk_forces_raw_fallback() { + for content in ["arbitrary metadata", ""] { + let diff = format!( + "--- a/value.rs\n+++ b/value.rs\n{content}\n@@ -1 +1 @@\n-let old = 1;\n+let value = 2;\n" + ); + + assert_malformed_raw_fallback(&diff); + } + } + + #[test] + fn prelude_before_header_pair_remains_valid() { + let diff = "\ +diff --git a/value.rs b/value.rs +--- a/value.rs ++++ b/value.rs +@@ -1 +1 @@ +-let old = 1; ++let value = 2; +"; + + let parsed = parse_unified_diff(diff); + + assert!(parsed.is_structurally_valid()); + assert!(!parsed.has_multiple_files); + assert_eq!( + parsed.prelude, + vec![ + "diff --git a/value.rs b/value.rs", + "--- a/value.rs", + "+++ b/value.rs", + ] + ); + } + + #[test] + fn headerless_hunk_followed_by_headered_section_disables_refinement() { + let theme = dark_theme(); + let overlay_color = Color::Rgb(1, 2, 3); + let diff = "\ +@@ -1 +1 @@ +-let old = 1; ++let value = 2; +--- a/other.rs ++++ b/other.rs +@@ -1 +1 @@ +-let other = 1; ++let other = 2; +"; + let mut refined = RefinedDiffStyles::new(); + refined.insert( + 1, + vec![Span::styled( + "refined overlay must not render", + Style::default().fg(overlay_color), + )], + ); + + let parsed = parse_unified_diff(diff); + let rendered = render_parsed_diff(&parsed, &theme, Some(&refined)); + + assert!(parsed.is_structurally_valid()); + assert!(parsed.has_multiple_files); + assert_eq!(parsed.hunks.len(), 2); + assert!( + compute_parsed_diff_file_scoped_styles( + Path::new("other.rs"), + "let other = 2;\n", + &parsed, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + vec![ + "⋮ ⋮ ", + "1 - let old = 1;", + " 1 + let value = 2;", + " --- a/other.rs", + " +++ b/other.rs", + "⋮ ⋮ ", + "1 - let other = 1;", + " 1 + let other = 2;", + ] + ); + assert_plain_source( + find_rendered_line(&rendered, "+let value = 2;"), + " +", + "let value = 2;", + theme.diff_add, + ); + assert_plain_source( + find_rendered_line(&rendered, "+let other = 2;"), + " +", + "let other = 2;", + theme.diff_add, + ); + assert!( + rendered + .iter() + .flat_map(|line| &line.spans) + .all(|span| span.style.fg != Some(overlay_color)) + ); + } + + #[test] + fn multiple_headerless_hunks_remain_one_implicit_section() { + let diff = "\ +@@ -1 +1 @@ first +-old = 1 ++new = 1 +@@ -3 +3 @@ second +-old = 3 ++new = 3 +"; + + let parsed = parse_unified_diff(diff); + + assert!(parsed.is_structurally_valid()); + assert!(!parsed.has_multiple_files); + assert_eq!(parsed.hunks.len(), 2); + } + + #[test] + fn positive_count_at_zero_start_forces_first_paint_raw_fallback() { + for diff in [ + "\ +--- a/value.rs ++++ b/value.rs +@@ -0 +1 @@ +-let old = 1; ++let value = 2; +", + "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +0 @@ +-let old = 1; ++let value = 2; +", + ] { + assert_malformed_raw_fallback(diff); + } + } + + #[test] + fn zero_width_hunk_forces_first_paint_raw_fallback() { + assert_malformed_raw_fallback( + "\ +--- a/value.rs ++++ b/value.rs +@@ -1,0 +1,0 @@ +@@ -1 +1 @@ +-let old = 1; ++let value = 2; +", + ); + } + + #[test] + fn overflowing_hunk_endpoint_forces_first_paint_raw_fallback() { + let diff = format!( + "--- a/value.rs\n+++ b/value.rs\n@@ -{},2 +1,2 @@\n-old = 1\n-old = 2\n+value = 2\n+new = 2\n", + usize::MAX + ); + + assert_malformed_raw_fallback(&diff); + } + + #[test] + fn overflowing_zero_count_anchor_forces_first_paint_raw_fallback() { + let diff = format!( + "--- a/value.rs\n+++ b/value.rs\n@@ -{},0 +1 @@\n+let value = 2\n", + usize::MAX + ); + + assert_malformed_raw_fallback(&diff); + } + + #[test] + fn duplicate_hunk_ranges_force_first_paint_raw_fallback() { + assert_malformed_raw_fallback( + "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +1 @@ +-old = 1 ++value = 2 +@@ -1 +1 @@ +-old = 1 ++value = 2 +", + ); + } + + #[test] + fn backward_hunk_forces_first_paint_raw_fallback() { + assert_malformed_raw_fallback( + "\ +--- a/value.rs ++++ b/value.rs +@@ -3 +3 @@ +-old = 3 ++new = 3 +@@ -1 +1 @@ +-old = 1 ++value = 2 +", + ); + } + + #[test] + fn old_side_overlap_forces_first_paint_raw_fallback() { + assert_malformed_raw_fallback( + "\ +--- a/value.rs ++++ b/value.rs +@@ -1,2 +1 @@ +-old = 1 +-old = 2 ++new = 1 +@@ -2 +2 @@ +-old = 2 ++value = 2 +", + ); + } + + #[test] + fn new_side_overlap_forces_first_paint_raw_fallback() { + assert_malformed_raw_fallback( + "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +1,2 @@ +-old = 1 ++new = 1 ++value = 2 +@@ -2 +2 @@ +-old = 2 ++value = 2 +", + ); + } + + #[test] + fn zero_count_old_anchor_cannot_be_reused_by_later_positive_range() { + assert_malformed_raw_fallback( + "\ +--- a/value.rs ++++ b/value.rs +@@ -1,0 +1 @@ ++let first = 1; +@@ -1 +3 @@ +-let old = 1; ++let value = 2; +", + ); + } + + #[test] + fn zero_count_new_anchor_cannot_be_reused_by_later_positive_range() { + assert_malformed_raw_fallback( + "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +1,0 @@ +-let old = 1; +@@ -3 +1 @@ +-let other = 3; ++let value = 2; +", + ); + } + + #[test] + fn zero_count_anchor_allows_later_range_at_next_coordinate() { + for diff in [ + "\ +--- a/value.rs ++++ b/value.rs +@@ -1,0 +1 @@ ++let first = 1; +@@ -2 +3 @@ +-let old = 2; ++let value = 3; +", + "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +1,0 @@ +-let old = 1; +@@ -3 +2 @@ +-let other = 3; ++let value = 2; +", + ] { + let parsed = parse_unified_diff(diff); + + assert!(parsed.is_structurally_valid(), "{diff}"); + assert!(!parsed.has_multiple_files, "{diff}"); + } + } + + #[test] + fn dev_null_range_mismatch_forces_first_paint_raw_fallback() { + for diff in [ + "\ +--- /dev/null ++++ b/value.rs +@@ -1,0 +1 @@ ++let value = 2; +", + "\ +--- a/value.rs ++++ /dev/null +@@ -1 +1,0 @@ +-let old = 1; +", + "\ +--- /dev/null ++++ /dev/null +@@ -0,0 +1 @@ ++let value = 2; +", + ] { + assert_malformed_raw_fallback(diff); + } + } + + #[test] + fn structural_hunk_controls_remain_valid() { + for diff in [ + "\ +--- /dev/null ++++ b/added.rs +@@ -0,0 +1 @@ ++fn added() {} +", + "\ +--- a/deleted.rs ++++ /dev/null +@@ -1 +0,0 @@ +-fn deleted() {} +", + "\ +--- a/value.rs ++++ b/value.rs +@@ -1,0 +2 @@ insertion point ++let value = 2; +", + "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +1 @@ first function +-old = 1 ++new = 1 +@@ -3 +3 @@ second function +-old = 3 ++new = 3 +", + ] { + let parsed = parse_unified_diff(diff); + assert!( + !parsed.has_malformed_hunk, + "valid control rejected:\n{diff}" + ); + assert!(parsed.is_structurally_valid()); + assert!(parsed.raw_fallback.is_none()); + } + } + + #[test] + fn multi_file_sections_reset_hunk_range_ordering() { + let diff = "\ +--- a/first.rs ++++ b/first.rs +@@ -10 +10 @@ +-old = 10 ++new = 10 +--- a/second.rs ++++ b/second.rs +@@ -1 +1 @@ second function +-old = 1 ++new = 1 +"; + + let parsed = parse_unified_diff(diff); + + assert!(parsed.has_multiple_files); + assert!(!parsed.has_malformed_hunk); + assert!(parsed.is_structurally_valid()); + assert!(parsed.raw_fallback.is_none()); + } + + #[test] + fn standard_git_multi_file_separator_is_structurally_valid_and_plain() { + let theme = dark_theme(); + let overlay_color = Color::Rgb(1, 2, 3); + let diff = "\ +diff --git a/a.rs b/a.rs +index 111..222 100644 +--- a/a.rs ++++ b/a.rs +@@ -1 +1 @@ +-old_a ++new_a +diff --git a/b.py b/b.py +index 333..444 100644 +--- a/b.py ++++ b/b.py +@@ -1 +1 @@ +-old_b ++new_b +"; + let mut refined = RefinedDiffStyles::new(); + refined.insert( + 1, + vec![Span::styled( + "refined overlay must not render", + Style::default().fg(overlay_color), + )], + ); + + let parsed = parse_unified_diff(diff); + let rendered = render_parsed_diff(&parsed, &theme, Some(&refined)); + + assert!(parsed.has_multiple_files); + assert!(parsed.is_structurally_valid()); + assert!(parsed.raw_fallback.is_none()); + assert!( + compute_parsed_diff_file_scoped_styles( + Path::new("b.py"), + "new_b\n", + &parsed, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + vec![ + " diff --git a/a.rs b/a.rs", + " index 111..222 100644", + " --- a/a.rs", + " +++ b/a.rs", + "⋮ ⋮ ", + "1 - old_a", + " 1 + new_a", + " diff --git a/b.py b/b.py", + " index 333..444 100644", + " --- a/b.py", + " +++ b/b.py", + "⋮ ⋮ ", + "1 - old_b", + " 1 + new_b", + ] + ); + assert_plain_source( + find_rendered_line(&rendered, "+new_a"), + " +", + "new_a", + theme.diff_add, + ); + assert_plain_source( + find_rendered_line(&rendered, "+new_b"), + " +", + "new_b", + theme.diff_add, + ); + assert!( + rendered + .iter() + .flat_map(|line| &line.spans) + .all(|span| span.style.fg != Some(overlay_color)) + ); + } + + #[test] + fn known_git_extended_headers_between_sections_are_allowed() { + let metadata = [ + "index 111..222 100644", + "new file mode 100644", + "deleted file mode 100644", + "old mode 100644", + "new mode 100755", + "similarity index 90%", + "dissimilarity index 10%", + "rename from old.py", + "rename to new.py", + "copy from source.py", + "copy to destination.py", + ] + .join("\n"); + let diff = format!( + "--- a/a.rs\n+++ b/a.rs\n@@ -1 +1 @@\n-old_a\n+new_a\n\ + diff --git a/b.py b/b.py\n{metadata}\n--- a/b.py\n+++ b/b.py\n\ + @@ -1 +1 @@\n-old_b\n+new_b\n" + ); + + let parsed = parse_unified_diff(&diff); + + assert!(parsed.has_multiple_files); + assert!(parsed.is_structurally_valid()); + assert!(parsed.raw_fallback.is_none()); + } + + #[test] + fn headerless_hunk_then_git_section_is_valid_but_ambiguous() { + let diff = "\ +@@ -1 +1 @@ +-old_a ++new_a +diff --git a/b.py b/b.py +index 333..444 100644 +--- a/b.py ++++ b/b.py +@@ -1 +1 @@ +-old_b ++new_b +"; + + let parsed = parse_unified_diff(diff); + + assert!(parsed.has_multiple_files); + assert!(parsed.is_structurally_valid()); + assert!(parsed.raw_fallback.is_none()); + assert_eq!(parsed.hunks.len(), 2); + } + + #[test] + fn declared_initial_git_section_rejects_unknown_metadata_or_missing_pair() { + for diff in [ + "\ +diff --git a/a.rs b/a.rs +unknown metadata +--- a/a.rs ++++ b/a.rs +@@ -1 +1 @@ +-old_a ++new_a +", + "\ +diff --git a/a.rs b/a.rs +index 111..222 100644 +", + ] { + assert_malformed_raw_fallback(diff); + } + } + + #[test] + fn unknown_git_separator_metadata_forces_raw_fallback() { + assert_malformed_raw_fallback( + "\ +--- a/a.rs ++++ b/a.rs +@@ -1 +1 @@ +-old_a ++new_a +diff --git a/b.py b/b.py +unknown metadata +--- a/b.py ++++ b/b.py +@@ -1 +1 @@ +-old_b ++new_b +", + ); + } + + #[test] + fn git_separator_without_header_pair_forces_raw_fallback() { + for diff in [ + "\ +--- a/a.rs ++++ b/a.rs +@@ -1 +1 @@ +-old_a ++new_a +diff --git a/b.py b/b.py +index 333..444 100644 +", + "\ +--- a/a.rs ++++ b/a.rs +@@ -1 +1 @@ +-old_a ++new_a +diff --git a/b.py b/b.py +index 333..444 100644 +diff --git a/c.py b/c.py +--- a/c.py ++++ b/c.py +@@ -1 +1 @@ +-old_c ++new_c +", + "\ +--- a/a.rs ++++ b/a.rs +@@ -1 +1 @@ +-old_a ++new_a +diff --git a/b.py b/b.py +@@ -3 +3 @@ +-old_b ++new_b +", + ] { + assert_malformed_raw_fallback(diff); + } + } + + #[test] + fn standalone_git_extended_header_after_hunk_forces_raw_fallback() { + assert_malformed_raw_fallback( + "\ +--- a/a.rs ++++ b/a.rs +@@ -1 +1 @@ +-old_a ++new_a +index 333..444 100644 +--- a/b.py ++++ b/b.py +@@ -1 +1 @@ +-old_b ++new_b +", + ); + } + + #[test] + fn headerless_fragment_keeps_legacy_add_remove_and_hunk_colors() { + let theme = dark_theme(); + + let rendered = render_unified_diff("-old\n+new\n@@ marker", &theme, None); + + assert_eq!(rendered.len(), 3); + assert_eq!(rendered_text(&rendered[0]), " -old"); + assert_eq!(rendered[0].spans[0].style.fg, Some(theme.diff_remove)); + assert_eq!(rendered_text(&rendered[1]), " +new"); + assert_eq!(rendered[1].spans[0].style.fg, Some(theme.diff_add)); + assert_eq!(rendered_text(&rendered[2]), " @@ marker"); + assert_eq!(rendered[2].spans[0].style.fg, Some(theme.border)); + } + + #[test] + fn multiple_file_sections_disable_syntax_and_refined_overlays() { + let theme = dark_theme(); + let first_insert = "fn first() { let value = 1; }"; + let diff = format!( + "\ +--- a/first.rs ++++ b/first.rs +@@ -1 +1 @@ +-fn old() {{}} ++{first_insert} +--- a/second.py ++++ b/second.py +@@ -1 +1 @@ +-value = 0 ++value = 1 +" + ); + let mut refined = RefinedDiffStyles::new(); + refined.insert( + 1, + vec![Span::styled( + first_insert, + Style::default().fg(ratatui::style::Color::Magenta), + )], + ); + + let rendered = render_unified_diff(&diff, &theme, Some(&refined)); + let first = find_rendered_line(&rendered, "+fn first"); + let second = find_rendered_line(&rendered, "+value = 1"); + + assert_plain_source(first, " +", first_insert, theme.diff_add); + assert_plain_source(second, " +", "value = 1", theme.diff_add); + assert!( + rendered + .iter() + .flat_map(|line| &line.spans) + .all(|span| span.style.fg != Some(ratatui::style::Color::Magenta)) + ); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + vec![ + " --- a/first.rs", + " +++ b/first.rs", + "⋮ ⋮ ", + "1 - fn old() {}", + " 1 + fn first() { let value = 1; }", + " --- a/second.py", + " +++ b/second.py", + "⋮ ⋮ ", + "1 - value = 0", + " 1 + value = 1", + ] + ); + } + + #[test] + fn standalone_new_header_after_completed_hunk_marks_whole_diff_malformed() { + let theme = dark_theme(); + let inserted = "fn current() { let value = 1; }"; + let diff = format!( + "\ +--- a/current.rs ++++ b/current.rs +@@ -1 +1 @@ +-fn old() {{}} ++{inserted} ++++ b/other.rs +" + ); + let mut refined = RefinedDiffStyles::new(); + refined.insert( + 1, + vec![Span::styled( + inserted, + Style::default().fg(ratatui::style::Color::Magenta), + )], + ); + + let parsed = parse_unified_diff(&diff); + assert!(parsed.has_malformed_hunk); + assert!(!super::syntax_is_eligible(&parsed)); + + let rendered = render_parsed_diff(&parsed, &theme, Some(&refined)); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + diff.lines() + .map(|line| format!(" {line}")) + .collect::>() + ); + assert!(rendered.iter().all(|line| line.spans.len() == 1)); + assert_eq!(rendered[4].spans[0].style.fg, Some(theme.diff_add)); + assert_eq!(rendered[5].spans[0].style.fg, Some(theme.muted)); + assert!( + rendered + .iter() + .flat_map(|line| &line.spans) + .all(|span| span.style.fg != Some(ratatui::style::Color::Magenta)) + ); + } + + #[test] + fn unpaired_old_header_after_completed_hunk_marks_whole_diff_malformed() { + let theme = dark_theme(); + let diff = "\ +--- a/current.rs ++++ b/current.rs +@@ -1 +1 @@ +-fn old() {} ++fn current() {} +--- a/other.rs +metadata instead of paired new header +"; + + let parsed = parse_unified_diff(diff); + assert!(parsed.has_malformed_hunk); + assert_eq!(parsed.raw_fallback.as_deref(), Some(diff)); + assert!(!super::syntax_is_eligible(&parsed)); + + let rendered = render_parsed_diff(&parsed, &theme, None); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + diff.lines() + .map(|line| format!(" {line}")) + .collect::>() + ); + assert!(rendered.iter().all(|line| line.spans.len() == 1)); + assert_eq!(rendered[5].spans[0].style.fg, Some(theme.muted)); + } + + #[test] + fn timestamped_second_file_header_pair_is_valid_but_syntax_ineligible() { + let theme = dark_theme(); + let diff = "\ +--- a/one.py\t2026-07-24 10:00:00 ++++ b/one.py\t2026-07-24 10:01:00 +@@ -1 +1 @@ +-value = 0 ++value = 1 +--- a/two.py\t2026-07-24 10:02:00 ++++ b/two.py\t2026-07-24 10:03:00 +@@ -1 +1 @@ +-other = 0 ++other = 1 +"; + + let parsed = parse_unified_diff(diff); + assert!(parsed.has_multiple_files); + assert!(!parsed.has_malformed_hunk); + assert!(!super::syntax_is_eligible(&parsed)); + + let rendered = render_parsed_diff(&parsed, &theme, None); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + vec![ + " --- a/one.py\t2026-07-24 10:00:00", + " +++ b/one.py\t2026-07-24 10:01:00", + "⋮ ⋮ ", + "1 - value = 0", + " 1 + value = 1", + " --- a/two.py\t2026-07-24 10:02:00", + " +++ b/two.py\t2026-07-24 10:03:00", + "⋮ ⋮ ", + "1 - other = 0", + " 1 + other = 1", + ] + ); + assert_plain_source( + find_rendered_line(&rendered, "+value = 1"), + " +", + "value = 1", + theme.diff_add, + ); + assert_plain_source( + find_rendered_line(&rendered, "+other = 1"), + " +", + "other = 1", + theme.diff_add, + ); + } + + #[test] + fn parsed_diff_file_scoped_styles_reject_multiple_files() { + let theme = dark_theme(); + let file_text = "value = 1\n"; + let parsed = parse_unified_diff( + "\ +--- a/first.py ++++ b/first.py +@@ -1 +1 @@ + value = 1 +--- a/second.py ++++ b/second.py +@@ -1 +1 @@ + value = 1 +", + ); + + assert!(parsed.has_multiple_files); + assert!( + compute_file_scoped_styles( + Path::new("first.py"), + file_text, + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_some(), + "low-level hunk API intentionally has no ParsedDiff ambiguity guard" + ); + assert!( + compute_parsed_diff_file_scoped_styles( + Path::new("first.py"), + file_text, + &parsed, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn destination_extension_wins_for_rename_and_dev_null_uses_real_path() { + let renamed = parse_unified_diff( + "\ +--- a/src/value.unknown\t2026-07-24 ++++ b/src/value.py\t2026-07-24 +@@ -1 +1 @@ +-old ++new +", + ); + let added = parse_unified_diff( + "\ +--- /dev/null ++++ b/src/added.rs +@@ -0,0 +1 @@ ++fn added() {} +", + ); + let deleted = parse_unified_diff( + "\ +--- a/src/deleted.py ++++ /dev/null +@@ -1 +0,0 @@ +-print('deleted') +", + ); + + assert_eq!(renamed.destination_path.as_deref(), Some("src/value.py")); + assert_eq!(added.destination_path.as_deref(), Some("src/added.rs")); + assert_eq!(deleted.destination_path.as_deref(), Some("src/deleted.py")); + } + + #[test] + fn file_header_markers_inside_hunks_are_source_lines() { + let parsed = parse_unified_diff( + "\ +--- a/value.txt ++++ b/value.txt +@@ -1 +1 @@ +--- old heading ++++ new heading +", + ); + let source = parsed.hunks[0].source_lines().collect::>(); + + assert_eq!(source[0].kind, DiffLineKind::Delete); + assert_eq!(source[0].content, "-- old heading"); + assert_eq!(source[1].kind, DiffLineKind::Insert); + assert_eq!(source[1].content, "++ new heading"); + } + + #[test] + fn metadata_lines_remain_in_exact_render_order() { + let theme = dark_theme(); + let diff = concat!( + "diff --git a/value.rs b/value.rs\n", + "--- a/value.rs\n", + "+++ b/value.rs\n", + "@@ -1 +1 @@\n", + "-let value = 1;\n", + "\\ No newline at end of file\n", + "+let value = 2;\n", + "malformed metadata\n", + ); + + let parsed = parse_unified_diff(diff); + assert!(matches!( + &parsed.hunks[0].entries[1], + DiffHunkEntry::Metadata(line) if line == "\\ No newline at end of file" + )); + let rendered = render_unified_diff(diff, &theme, None) + .iter() + .map(rendered_text) + .collect::>(); + + assert_eq!( + rendered, + vec![ + " diff --git a/value.rs b/value.rs", + " --- a/value.rs", + " +++ b/value.rs", + " @@ -1 +1 @@", + " -let value = 1;", + " \\ No newline at end of file", + " +let value = 2;", + " malformed metadata", + ] + ); + } + + #[test] + fn impossible_source_marker_marks_hunk_malformed_and_renders_all_lines_plain() { + let theme = dark_theme(); + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -1,0 +1 @@ +-bogus ++let valid = 1; +"; + + let parsed = parse_unified_diff(diff); + assert!(parsed.has_malformed_hunk); + assert!(!super::syntax_is_eligible(&parsed)); + + let rendered = render_parsed_diff(&parsed, &theme, None); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + diff.lines() + .map(|line| format!(" {line}")) + .collect::>() + ); + assert_eq!(rendered[3].spans.len(), 1); + assert_eq!(rendered[3].spans[0].style.fg, Some(theme.diff_remove)); + assert_eq!(rendered[4].spans.len(), 1); + assert_eq!(rendered[4].spans[0].style.fg, Some(theme.diff_add)); + } + + #[test] + fn premature_next_hunk_marks_whole_diff_malformed_without_dropping_lines() { + let theme = dark_theme(); + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -1,2 +1,2 @@ + shared(); +@@ -10 +10 @@ +-let old = 1; ++let new = 2; +"; + + let parsed = parse_unified_diff(diff); + assert!(parsed.has_malformed_hunk); + assert!(!super::syntax_is_eligible(&parsed)); + + let rendered = render_parsed_diff(&parsed, &theme, None); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + diff.lines() + .map(|line| format!(" {line}")) + .collect::>() + ); + assert!(rendered.iter().all(|line| line.spans.len() == 1)); + assert_eq!(rendered[2].spans[0].style.fg, Some(theme.border)); + assert_eq!(rendered[4].spans[0].style.fg, Some(theme.border)); + } + + #[test] + fn source_marker_after_completed_hunk_marks_diff_malformed() { + let theme = dark_theme(); + let diff = "\ +--- /dev/null ++++ b/value.rs +@@ -0,0 +1 @@ ++let first = 1; ++let extra = 2; +"; + + let parsed = parse_unified_diff(diff); + assert!(parsed.has_malformed_hunk); + assert!(!super::syntax_is_eligible(&parsed)); + + let rendered = render_parsed_diff(&parsed, &theme, None); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + diff.lines() + .map(|line| format!(" {line}")) + .collect::>() + ); + assert_eq!(rendered[3].spans.len(), 1); + assert_eq!(rendered[4].spans.len(), 1); + assert_eq!(rendered[4].spans[0].style.fg, Some(theme.diff_add)); + } + + #[test] + fn valid_zero_count_insertion_and_deletion_hunks_remain_eligible() { + let insertion = parse_unified_diff( + "\ +--- /dev/null ++++ b/added.rs +@@ -0,0 +1 @@ ++fn added() {} +", + ); + let deletion = parse_unified_diff( + "\ +--- a/deleted.rs ++++ /dev/null +@@ -1 +0,0 @@ +-fn deleted() {} +", + ); + + for parsed in [&insertion, &deletion] { + assert!(!parsed.has_malformed_hunk); + assert!(super::syntax_is_eligible(parsed)); + assert_eq!(parsed.hunks.len(), 1); + assert_eq!(parsed.hunks[0].source_lines().count(), 1); + } + } + + #[test] + fn incomplete_hunk_at_eof_is_malformed_without_panicking() { + let theme = dark_theme(); + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -1 +1 @@ ++let replacement = 1; +"; + + let parsed = parse_unified_diff(diff); + assert!(parsed.has_malformed_hunk); + assert!(!super::syntax_is_eligible(&parsed)); + assert_eq!( + render_parsed_diff(&parsed, &theme, None) + .iter() + .map(rendered_text) + .collect::>(), + diff.lines() + .map(|line| format!(" {line}")) + .collect::>() + ); + } + + #[test] + fn rust_source_render_keeps_exact_prefix_and_tokenizes_content_without_marker() { + let theme = dark_theme(); + let lines = render_unified_diff(RUST_DIFF, &theme, None); + let inserted = find_rendered_line(&lines, "+fn new"); + + assert_eq!(inserted.spans[0].content.as_ref(), " 1 + "); + assert_eq!(inserted.spans[0].style.fg, Some(theme.diff_add)); + assert_eq!( + inserted.spans[1..] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "fn new() { let value = \"new\"; }" + ); + assert!( + !inserted.spans[1..] + .iter() + .any(|span| span.content.starts_with('+')) + ); + let token_foregrounds = inserted.spans[1..] + .iter() + .filter_map(|span| span.style.fg) + .collect::>(); + assert!(token_foregrounds.len() >= 2); + } + + #[test] + fn old_and_new_hunk_parsers_are_independent() { + let theme = dark_theme(); + let diff = "\ +--- a/item.py ++++ b/item.py +@@ -1,2 +1,2 @@ +-\"\"\"old string +-still old ++value = 1 ++print(value) +"; + + let lines = render_unified_diff(diff, &theme, None); + let deleted = find_rendered_line(&lines, "-still old"); + let value = find_rendered_line(&lines, "+value = 1"); + let print = find_rendered_line(&lines, "+print(value)"); + let old_expected = + highlight_sequence("item.py", &["\"\"\"old string", "still old"], &theme); + let new_expected = highlight_sequence("item.py", &["value = 1", "print(value)"], &theme); + let leaked = highlight_sequence( + "item.py", + &["\"\"\"old string", "still old", "value = 1"], + &theme, + ); + + assert_same_styled_text(&deleted.spans[1..], &old_expected[1]); + assert_same_styled_text(&value.spans[1..], &new_expected[0]); + assert_same_styled_text(&print.spans[1..], &new_expected[1]); + assert_different_styled_text(&value.spans[1..], &leaked[2]); + } + + #[test] + fn context_advances_both_parsers_before_delete_and_insert() { + let theme = dark_theme(); + let diff = "\ +--- a/item.py ++++ b/item.py +@@ -1,2 +1,2 @@ + \"\"\"shared +-old tail\"\"\" ++new tail\"\"\" +"; + + let lines = render_unified_diff(diff, &theme, None); + let deleted = find_rendered_line(&lines, "-old tail"); + let inserted = find_rendered_line(&lines, "+new tail"); + let old_expected = + highlight_sequence("item.py", &["\"\"\"shared", "old tail\"\"\""], &theme); + let new_expected = + highlight_sequence("item.py", &["\"\"\"shared", "new tail\"\"\""], &theme); + let fresh_old = highlight_sequence("item.py", &["old tail\"\"\""], &theme); + let fresh_new = highlight_sequence("item.py", &["new tail\"\"\""], &theme); + + assert_same_styled_text(&deleted.spans[1..], &old_expected[1]); + assert_same_styled_text(&inserted.spans[1..], &new_expected[1]); + assert_different_styled_text(&deleted.spans[1..], &fresh_old[0]); + assert_different_styled_text(&inserted.spans[1..], &fresh_new[0]); + } + + #[test] + fn one_parser_failure_falls_back_the_entire_hunk() { + let theme = dark_theme(); + let hunk = super::DiffHunk { + header: "@@ -1 +1 @@".to_owned(), + entries: vec![ + DiffHunkEntry::Source(super::DiffSourceLine { + kind: DiffLineKind::Insert, + old_line: None, + new_line: Some(1), + content: "let new = 2;".to_owned(), + }), + DiffHunkEntry::Source(super::DiffSourceLine { + kind: DiffLineKind::Delete, + old_line: Some(1), + new_line: None, + content: "let old = 1;".to_owned(), + }), + ], + }; + let mut highlighted_new = false; + + let rendered = super::render_hunk_with( + &hunk, + hunk.entries.len(), + &theme, + None, + 1, + Instant::now() + INLINE_DIFF_DEADLINE, + |side, content| match side { + super::DiffSide::New => { + highlighted_new = true; + Some(vec![Span::styled( + content.to_owned(), + Style::default().fg(ratatui::style::Color::Magenta), + )]) + } + super::DiffSide::Old => { + assert!( + highlighted_new, + "old-side failure must happen after a successful new-side line" + ); + None + } + }, + ); + + assert_plain_source(&rendered[0], " 1 + ", "let new = 2;", theme.diff_add); + assert_plain_source(&rendered[1], "1 - ", "let old = 1;", theme.diff_remove); + } + + #[test] + fn parsed_render_does_not_highlight_hunk_entries_beyond_visible_budget() { + let theme = dark_theme(); + let diff = format!( + "--- a/value.rs\n+++ b/value.rs\n@@ -0,0 +1,100 @@\n{}", + (0..100) + .map(|index| format!("+let value_{index} = {index};\n")) + .collect::() + ); + let parsed = parse_unified_diff(&diff); + let mut highlight_calls = 0usize; + let visible_source_entries = + super::MAX_RENDERED_DIFF_LINES - parsed.prelude.len() - parsed.hunks.len(); + + let rendered = + super::render_parsed_diff_with(&parsed, &theme, |hunk, entry_budget, gutter_width| { + super::render_hunk_with( + hunk, + entry_budget, + &theme, + None, + gutter_width, + Instant::now() + INLINE_DIFF_DEADLINE, + |_side, content| { + highlight_calls += 1; + Some(vec![Span::raw(content.to_owned())]) + }, + ) + }); + + assert_eq!(rendered.len(), 81); + assert_eq!(rendered_text(&rendered[79]), " 77 + let value_76 = 76;"); + assert_eq!(rendered_text(&rendered[80]), " [... diff truncated ...]"); + assert_eq!(highlight_calls, visible_source_entries); + } + + #[test] + fn hunk_boundary_resets_multiline_parser_state() { + let theme = dark_theme(); + let diff = "\ +--- a/item.py ++++ b/item.py +@@ -1 +1 @@ + \"\"\"open string +@@ -20,0 +20 @@ ++value = 1 +"; + + let lines = render_unified_diff(diff, &theme, None); + let inserted = find_rendered_line(&lines, "+value = 1"); + let fresh = highlight_sequence("item.py", &["value = 1"], &theme); + let leaked = highlight_sequence("item.py", &["\"\"\"open string", "value = 1"], &theme); + + assert_same_styled_text(&inserted.spans[1..], &fresh[0]); + assert_different_styled_text(&inserted.spans[1..], &leaked[1]); + } + + #[test] + fn standard_metadata_does_not_advance_parser_state() { + let theme = dark_theme(); + let diff = "\ +--- a/item.py ++++ b/item.py +@@ -1 +1,2 @@ + \"\"\"open string +\\ No newline at end of file ++value = 1 +"; + + let parsed = parse_unified_diff(diff); + let mut highlighted_content = Vec::new(); + let rendered = super::render_hunk_with( + &parsed.hunks[0], + parsed.hunks[0].entries.len(), + &theme, + None, + 1, + Instant::now() + INLINE_DIFF_DEADLINE, + |_side, content| { + highlighted_content.push(content.to_owned()); + Some(vec![Span::raw(content.to_owned())]) + }, + ); + + assert!(parsed.is_structurally_valid()); + assert_eq!( + highlighted_content, + vec!["\"\"\"open string", "\"\"\"open string", "value = 1"] + ); + assert_eq!( + rendered.iter().map(rendered_text).collect::>(), + vec![ + "1 1 \"\"\"open string", + " \\ No newline at end of file", + " 2 + value = 1", + ] + ); + } + + #[test] + fn aggregate_parser_byte_limit_is_strict_and_boundary_remains_eligible() { + let theme = dark_theme(); + let exact_source = padded_rust_line(MAX_HIGHLIGHT_LINE_BYTES - 1); + let over_source = padded_rust_line(MAX_HIGHLIGHT_LINE_BYTES); + let exact_diff = format!( + "--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1,128 @@\n{}", + (0..(MAX_HIGHLIGHT_BYTES / MAX_HIGHLIGHT_LINE_BYTES)) + .map(|_| format!("+{exact_source}\n")) + .collect::() + ); + let over_diff = format!( + "--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1,128 @@\n+{over_source}\n{}", + (1..(MAX_HIGHLIGHT_BYTES / MAX_HIGHLIGHT_LINE_BYTES)) + .map(|_| format!("+{exact_source}\n")) + .collect::() + ); + + let exact_parsed = parse_unified_diff(&exact_diff); + assert_eq!(exact_parsed.aggregate_source_bytes, MAX_HIGHLIGHT_BYTES); + assert_eq!(exact_parsed.aggregate_source_lines, 128); + assert!(super::syntax_is_eligible(&exact_parsed)); + let exact = render_unified_diff(&exact_diff, &theme, None); + let exact_source = find_rendered_line(&exact, "+let value"); + assert!(exact_source.spans.len() > 2); + + let over_parsed = parse_unified_diff(&over_diff); + assert_eq!(over_parsed.aggregate_source_bytes, MAX_HIGHLIGHT_BYTES + 1); + assert_eq!(over_parsed.aggregate_source_lines, 128); + assert!(!super::syntax_is_eligible(&over_parsed)); + let over = render_unified_diff(&over_diff, &theme, None); + let rendered_over_source = find_rendered_line(&over, "+let value"); + assert_plain_source(rendered_over_source, " +", &over_source, theme.diff_add); + } + + #[test] + fn aggregate_source_line_limit_is_strict_and_boundary_remains_eligible() { + let theme = dark_theme(); + let first = "let value = \"syntax\";"; + let exact_diff = format!( + "--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1,{MAX_HIGHLIGHT_LINES} @@\n+{first}\n{}", + "+x\n".repeat(MAX_HIGHLIGHT_LINES - 1) + ); + let over_diff = format!( + "--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1,{} @@\n+{first}\n{}", + MAX_HIGHLIGHT_LINES + 1, + "+x\n".repeat(MAX_HIGHLIGHT_LINES) + ); + + assert_eq!( + parse_unified_diff(&exact_diff).aggregate_source_lines, + MAX_HIGHLIGHT_LINES + ); + let exact = render_unified_diff(&exact_diff, &theme, None); + assert!(find_rendered_line(&exact, "+let value").spans.len() > 2); + + assert_eq!( + parse_unified_diff(&over_diff).aggregate_source_lines, + MAX_HIGHLIGHT_LINES + 1 + ); + let over = render_unified_diff(&over_diff, &theme, None); + assert_plain_source( + find_rendered_line(&over, "+let value"), + " +", + first, + theme.diff_add, + ); + } + + #[test] + fn source_line_byte_limit_is_strict_and_boundary_remains_eligible() { + let theme = dark_theme(); + let exact_source = padded_rust_line(MAX_HIGHLIGHT_LINE_BYTES); + let over_source = format!("{exact_source}x"); + let exact_diff = format!("--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1 @@\n+{exact_source}\n"); + let over_diff = format!("--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1 @@\n+{over_source}\n"); + + let exact = render_unified_diff(&exact_diff, &theme, None); + assert!(find_rendered_line(&exact, "+let value").spans.len() > 2); + + let over = render_unified_diff(&over_diff, &theme, None); + assert_plain_source( + find_rendered_line(&over, "+let value"), + " +", + &over_source, + theme.diff_add, + ); + } + + #[test] + fn aggregate_guardrails_also_disable_refined_overlays() { + let theme = dark_theme(); + let over_source = padded_rust_line(MAX_HIGHLIGHT_LINE_BYTES + 1); + let diff = format!("--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1 @@\n+{over_source}\n"); + let mut refined = RefinedDiffStyles::new(); + refined.insert( + 1, + vec![Span::styled( + over_source.clone(), + Style::default().fg(ratatui::style::Color::Magenta), + )], + ); + + let rendered = render_unified_diff(&diff, &theme, Some(&refined)); + + assert_plain_source( + find_rendered_line(&rendered, "+let value"), + " +", + &over_source, + theme.diff_add, + ); + } + + #[test] + fn unknown_extension_uses_plain_class_colors() { + let theme = dark_theme(); + let diff = "\ +--- a/value.unknown ++++ b/value.unknown +@@ -1,2 +1,2 @@ +-old value ++new value + shared value +"; + + let lines = render_unified_diff(diff, &theme, None); + + assert_plain_source( + find_rendered_line(&lines, "-old value"), + " -", + "old value", + theme.diff_remove, + ); + assert_plain_source( + find_rendered_line(&lines, "+new value"), + " +", + "new value", + theme.diff_add, + ); + assert_plain_source( + find_rendered_line(&lines, "shared value"), + " ", + "shared value", + theme.muted, + ); + } + + #[test] + fn truncation_consumes_eighty_original_lines_and_keeps_exact_marker() { + let theme = dark_theme(); + let diff = (0..81) + .map(|index| format!("metadata {index:02}")) + .collect::>() + .join("\n"); + + let lines = render_unified_diff(&diff, &theme, None); + + assert_eq!(lines.len(), 81); + assert_eq!(rendered_text(&lines[0]), " metadata 00"); + assert_eq!(rendered_text(&lines[79]), " metadata 79"); + assert_eq!(rendered_text(&lines[80]), " [... diff truncated ...]"); + assert_eq!(lines[80].spans[0].style.fg, Some(theme.muted)); + assert!( + !lines + .iter() + .any(|line| rendered_text(line).contains("metadata 80")) + ); + } + + #[test] + fn malformed_hunk_header_stays_plain_metadata_without_panicking() { + let theme = dark_theme(); + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ malformed coordinates @@ ++let value = 1; +"; + + let parsed = parse_unified_diff(diff); + assert!(parsed.has_malformed_hunk); + assert!(!parsed.is_structurally_valid()); + assert_eq!(parsed.raw_fallback.as_deref(), Some(diff)); + assert!(parsed.hunks.is_empty()); + assert_eq!( + parsed.prelude, + vec![ + "--- a/value.rs", + "+++ b/value.rs", + "@@ malformed coordinates @@", + "+let value = 1;", + ] + ); + let lines = render_unified_diff(diff, &theme, None); + let malformed = find_rendered_line(&lines, "@@ malformed"); + assert_eq!(malformed.spans[0].style.fg, Some(theme.border)); + } + + #[test] + fn crlf_structural_endings_are_not_kept_in_source_content() { + let parsed = parse_unified_diff( + "--- a/value.rs\r\n+++ b/value.rs\r\n@@ -1 +1 @@\r\n-old\r\n+new\r\n", + ); + let source = parsed.hunks[0].source_lines().collect::>(); + + assert_eq!(source[0].content, "old"); + assert_eq!(source[1].content, "new"); + } + + #[test] + fn refined_styles_overlay_only_exact_new_side_content() { + let theme = dark_theme(); + let diff = "\ +--- a/value.rs ++++ b/value.rs +@@ -1,2 +1,2 @@ +-let old = 1; ++let new = 2; + shared(); +"; + let mut refined = RefinedDiffStyles::new(); + refined.insert( + 1, + vec![Span::styled( + "let new = 2;", + Style::default().fg(ratatui::style::Color::Magenta), + )], + ); + refined.insert( + 2, + vec![Span::styled( + "wrong text", + Style::default().fg(ratatui::style::Color::Cyan), + )], + ); + + let rendered = render_unified_diff(diff, &theme, Some(&refined)); + let deleted = find_rendered_line(&rendered, "-let old"); + let inserted = find_rendered_line(&rendered, "+let new"); + let context = find_rendered_line(&rendered, "shared()"); + + assert_eq!( + inserted.spans[1].style.fg, + Some(ratatui::style::Color::Magenta) + ); + assert_ne!( + deleted.spans[1].style.fg, + Some(ratatui::style::Color::Magenta) + ); + assert_ne!(context.spans[1].style.fg, Some(ratatui::style::Color::Cyan)); + + let mut delete_only: RefinedDiffStyles = HashMap::new(); + delete_only.insert( + 1, + vec![Span::styled( + "let old = 1;", + Style::default().fg(ratatui::style::Color::Yellow), + )], + ); + let delete_rendered = render_unified_diff(diff, &theme, Some(&delete_only)); + assert_ne!( + find_rendered_line(&delete_rendered, "-let old").spans[1] + .style + .fg, + Some(ratatui::style::Color::Yellow) + ); + } + + #[test] + fn full_file_python_scope_warms_refined_field_styles_from_line_one() { + let theme = dark_theme(); + let file_text = "\ +class Item: + \"\"\"Summary. + \"\"\" + field = 1 +"; + let diff = "\ +--- a/item.py ++++ b/item.py +@@ -3,2 +3,2 @@ + \"\"\" +- field = 0 ++ field = 1 +"; + let parsed = parse_unified_diff(diff); + + let refined = compute_file_scoped_styles( + Path::new("item.py"), + file_text, + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .expect("verified full-file styles"); + let cold = render_parsed_diff(&parsed, &theme, None); + let warm = render_parsed_diff(&parsed, &theme, Some(&refined)); + let cold_field = find_rendered_line(&cold, "+ field = 1"); + let warm_field = find_rendered_line(&warm, "+ field = 1"); + let direct = highlight_sequence( + "item.py", + &[ + "class Item:", + " \"\"\"Summary.", + " \"\"\"", + " field = 1", + ], + &theme, + ); + + assert_eq!(refined.len(), 2); + assert!(refined.contains_key(&3)); + assert!(refined.contains_key(&4)); + assert_ne!(warm_field.spans[1..], cold_field.spans[1..]); + assert_eq!(refined.get(&4), Some(&direct[3])); + assert_same_styled_text(&warm_field.spans[1..], &direct[3]); + } + + #[test] + fn file_text_drift_rejects_the_entire_full_file_style_map() { + let theme = dark_theme(); + let parsed = parse_unified_diff( + "\ +--- a/item.py ++++ b/item.py +@@ -3,2 +3,2 @@ + \"\"\" +- field = 0 ++ field = 1 +", + ); + let drifted_file_text = "\ +class Item: + \"\"\"Summary. + \"\"\" + field = 2 +"; + + assert!( + compute_file_scoped_styles( + Path::new("item.py"), + drifted_file_text, + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn full_file_refinement_preserves_delete_line_spans() { + let theme = dark_theme(); + let file_text = "\ +class Item: + \"\"\"Summary. + \"\"\" + field = 1 +"; + let parsed = parse_unified_diff( + "\ +--- a/item.py ++++ b/item.py +@@ -3,2 +3,2 @@ + \"\"\" +- field = 0 ++ field = 1 +", + ); + let refined = compute_file_scoped_styles( + Path::new("item.py"), + file_text, + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .expect("verified full-file styles"); + + let cold = render_parsed_diff(&parsed, &theme, None); + let warm = render_parsed_diff(&parsed, &theme, Some(&refined)); + + assert_eq!( + find_rendered_line(&warm, "- field = 0").spans, + find_rendered_line(&cold, "- field = 0").spans + ); + } + + #[test] + fn full_file_duplicate_new_lines_reject_conflicts_and_dedupe_identical_text() { + let theme = dark_theme(); + let hunks_with = |second_content: &str| { + ["value = 1", second_content] + .into_iter() + .map(|content| DiffHunk { + header: "@@ -1 +1 @@".to_owned(), + entries: vec![DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Context, + old_line: Some(1), + new_line: Some(1), + content: content.to_owned(), + })], + }) + .collect::>() + }; + let conflicting = hunks_with("value = 2"); + let identical = hunks_with("value = 1"); + + assert!( + compute_file_scoped_styles( + Path::new("value.py"), + "value = 1\n", + &conflicting, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + + let deduped = compute_file_scoped_styles( + Path::new("value.py"), + "value = 1\n", + &identical, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .expect("identical duplicate"); + assert_eq!(deduped.len(), 1); + assert!(deduped.contains_key(&1)); + } + + #[test] + fn full_file_invalid_new_line_none_is_rejected() { + let theme = dark_theme(); + let hunks = vec![DiffHunk { + header: "@@ -1 +1 @@".to_owned(), + entries: vec![DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Context, + old_line: Some(1), + new_line: None, + content: "value = 1".to_owned(), + })], + }]; + + assert!( + compute_file_scoped_styles( + Path::new("value.py"), + "value = 1\n", + &hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn full_file_invalid_new_line_zero_is_rejected() { + let theme = dark_theme(); + let hunks = vec![DiffHunk { + header: "@@ -0,0 +0 @@".to_owned(), + entries: vec![DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + old_line: None, + new_line: Some(0), + content: "value = 1".to_owned(), + })], + }]; + + assert!( + compute_file_scoped_styles( + Path::new("value.py"), + "value = 1\n", + &hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn full_file_invalid_new_line_mixed_with_valid_rejects_entire_map() { + let theme = dark_theme(); + let hunks = vec![DiffHunk { + header: "@@ -1,2 +1,2 @@".to_owned(), + entries: vec![ + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Insert, + old_line: None, + new_line: Some(1), + content: "value = 1".to_owned(), + }), + DiffHunkEntry::Source(DiffSourceLine { + kind: DiffLineKind::Context, + old_line: Some(2), + new_line: None, + content: "ignored = 2".to_owned(), + }), + ], + }]; + + assert!( + compute_file_scoped_styles( + Path::new("value.py"), + "value = 1\nignored = 2\n", + &hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn full_file_missing_expected_line_beyond_eof_is_rejected() { + let theme = dark_theme(); + let parsed = parse_unified_diff( + "\ +--- a/value.py ++++ b/value.py +@@ -2 +2 @@ + expected = 2 +", + ); + + assert!( + compute_file_scoped_styles( + Path::new("value.py"), + "only_line = 1\n", + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn full_file_delete_only_hunks_produce_an_empty_style_map() { + let theme = dark_theme(); + let parsed = parse_unified_diff( + "\ +--- a/value.py ++++ /dev/null +@@ -1 +0,0 @@ +-removed = 1 +", + ); + + let refined = compute_file_scoped_styles( + Path::new("value.py"), + "", + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .expect("delete-only diff"); + + assert!(refined.is_empty()); + } + + #[test] + fn full_file_guardrails_reject_total_bytes_above_limit() { + let theme = dark_theme(); + let source_line = "x".repeat(MAX_HIGHLIGHT_LINE_BYTES - 1); + let parsed = parse_unified_diff(&format!( + "--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1 @@\n+{source_line}\n" + )); + let exact_bytes = + format!("{source_line}\n").repeat(MAX_HIGHLIGHT_BYTES / MAX_HIGHLIGHT_LINE_BYTES); + let over_bytes = + format!("{source_line}\n").repeat(MAX_HIGHLIGHT_BYTES / MAX_HIGHLIGHT_LINE_BYTES + 1); + + assert_eq!(exact_bytes.len(), MAX_HIGHLIGHT_BYTES); + assert_eq!(exact_bytes.lines().count(), 128); + assert!( + compute_file_scoped_styles( + Path::new("value.rs"), + &exact_bytes, + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_some() + ); + assert!(over_bytes.len() > MAX_HIGHLIGHT_BYTES); + assert!(over_bytes.lines().count() < MAX_HIGHLIGHT_LINES); + assert!( + compute_file_scoped_styles( + Path::new("value.rs"), + &over_bytes, + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn full_file_guardrails_reject_lines_above_byte_limit() { + let theme = dark_theme(); + let exact_line = "x".repeat(MAX_HIGHLIGHT_LINE_BYTES); + let exact_parsed = parse_unified_diff(&format!( + "--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1 @@\n+{exact_line}\n" + )); + let overlong_line = "x".repeat(MAX_HIGHLIGHT_LINE_BYTES + 1); + let overlong_parsed = parse_unified_diff(&format!( + "--- /dev/null\n+++ b/value.rs\n@@ -0,0 +1 @@\n+{overlong_line}\n" + )); + + assert!( + compute_file_scoped_styles( + Path::new("value.rs"), + &exact_line, + &exact_parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_some() + ); + assert!( + compute_file_scoped_styles( + Path::new("value.rs"), + &overlong_line, + &overlong_parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn full_file_guardrails_reject_too_many_lines() { + let theme = dark_theme(); + let parsed = parse_unified_diff( + "\ +--- /dev/null ++++ b/value.rs +@@ -0,0 +1 @@ ++x +", + ); + let mut over_lines = "x\n".repeat(MAX_HIGHLIGHT_LINES); + over_lines.push('x'); + + assert_eq!(over_lines.lines().count(), MAX_HIGHLIGHT_LINES + 1); + assert!( + compute_file_scoped_styles( + Path::new("value.rs"), + &over_lines, + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn full_file_walk_stops_immediately_after_the_highest_needed_line() { + let theme = dark_theme(); + let parsed = parse_unified_diff( + "\ +--- a/value.py ++++ b/value.py +@@ -2 +2 @@ + wanted = 2 +", + ); + let pathological_tail = "\"\"\"unterminated content after the requested range"; + let file_text = format!("prefix = 1\nwanted = 2\n{pathological_tail}\n"); + let mut highlighted_lines = 0usize; + + let refined = super::compute_file_scoped_styles_with( + Path::new("value.py"), + &file_text, + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + |highlighter, text| { + assert_ne!(text, pathological_tail); + highlighted_lines += 1; + highlighter.highlight_line(text) + }, + ) + .expect("bounded full-file pass"); + + assert_eq!(highlighted_lines, 2); + assert_eq!(refined.len(), 1); + assert!(refined.contains_key(&2)); + } + + #[test] + fn full_file_styles_preserve_whitespace_and_strip_structural_crlf() { + let theme = dark_theme(); + let parsed = parse_unified_diff(concat!( + "--- a/value.py\r\n", + "+++ b/value.py\r\n", + "@@ -1,2 +1,2 @@\r\n", + " value = 1\r\n", + " field = 2 \r\n", + )); + let refined = compute_file_scoped_styles( + Path::new("value.py"), + "value = 1\r\n field = 2 ", + &parsed.hunks, + theme.syntax_theme, + TerminalColorLevel::TrueColor, + ) + .expect("CRLF full-file styles"); + + assert_eq!(refined.len(), 2); + assert_eq!( + refined[&2] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + " field = 2 " + ); + assert!( + refined + .values() + .flatten() + .all(|span| !span.content.contains('\r')) + ); + } +} diff --git a/crates/orca-tui/src/display_text.rs b/crates/orca-tui/src/display_text.rs index 2ea1fa34..d764c39c 100644 --- a/crates/orca-tui/src/display_text.rs +++ b/crates/orca-tui/src/display_text.rs @@ -1,3 +1,4 @@ +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; pub(crate) fn truncate_to_display_width(text: &str, max_width: usize) -> String { @@ -13,13 +14,13 @@ pub(crate) fn truncate_to_display_width(text: &str, max_width: usize) -> String let content_width = max_width.saturating_sub(UnicodeWidthStr::width(ellipsis)); let mut truncated = String::new(); let mut width = 0usize; - for ch in text.chars() { - let ch_width = UnicodeWidthStr::width(ch.to_string().as_str()); - if width + ch_width > content_width { + for grapheme in text.graphemes(true) { + let grapheme_width = UnicodeWidthStr::width(grapheme); + if width + grapheme_width > content_width { break; } - truncated.push(ch); - width += ch_width; + truncated.push_str(grapheme); + width += grapheme_width; } truncated.push_str(ellipsis); truncated @@ -57,6 +58,33 @@ mod tests { assert_eq!(truncate_to_display_width("anything", 0), ""); } + #[test] + fn truncation_never_splits_extended_graphemes() { + for grapheme in ["e\u{301}", "👍🏽", "👨‍👩‍👧‍👦", "1️⃣"] { + let grapheme_width = unicode_width::UnicodeWidthStr::width(grapheme); + assert_eq!( + truncate_to_display_width(&format!("{grapheme}x"), grapheme_width + 1), + format!("{grapheme}x"), + "{grapheme:?}" + ); + assert_eq!( + truncate_to_display_width(&format!("{grapheme}x"), grapheme_width), + "…", + "{grapheme:?}" + ); + } + } + + #[test] + fn truncation_keeps_combining_emoji_and_keycap_clusters_atomic() { + assert_eq!(truncate_to_display_width("e\u{301}x", 1), "…"); + assert_eq!(truncate_to_display_width("e\u{301}xy", 2), "e\u{301}…"); + assert_eq!(truncate_to_display_width("👍🏽x", 2), "…"); + assert_eq!(truncate_to_display_width("👍🏽xy", 3), "👍🏽…"); + assert_eq!(truncate_to_display_width("1️⃣x", 2), "…"); + assert_eq!(truncate_to_display_width("1️⃣xy", 3), "1️⃣…"); + } + #[test] fn compact_long_text_bounds_transcript_rows_and_keeps_size() { let text = "目标内容".repeat(300); diff --git a/crates/orca-tui/src/edit_highlight_worker.rs b/crates/orca-tui/src/edit_highlight_worker.rs new file mode 100644 index 00000000..4cd9c06e --- /dev/null +++ b/crates/orca-tui/src/edit_highlight_worker.rs @@ -0,0 +1,1138 @@ +#![cfg_attr(not(test), allow(dead_code))] + +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; + +use crossbeam_channel::{Receiver, Sender, TryRecvError}; + +use crate::diff_highlight::{ + ParsedDiff, RefinedDiffStyles, compute_parsed_diff_file_scoped_styles, +}; +use crate::syntax_highlight::{MAX_HIGHLIGHT_BYTES, SyntaxTheme, content_within_limits}; +use crate::terminal_capabilities::TerminalColorLevel; + +#[derive(Clone, Debug)] +pub(crate) struct EditHighlightJob { + pub(crate) job_id: u64, + pub(crate) tool_id: String, + pub(crate) message_index: usize, + pub(crate) message_revision: u64, + pub(crate) syntax_theme_revision: u64, + pub(crate) syntax_theme: SyntaxTheme, + pub(crate) syntax_color_level: TerminalColorLevel, + pub(crate) absolute_path: PathBuf, + pub(crate) display_path: String, + pub(crate) parsed: ParsedDiff, +} + +#[derive(Clone, Debug)] +pub(crate) enum EditHighlightOutcome { + Ready { styles: Arc }, + Failed, +} + +#[derive(Clone, Debug)] +pub(crate) struct EditHighlightResult { + pub(crate) job: EditHighlightJob, + pub(crate) outcome: EditHighlightOutcome, +} + +pub(crate) struct DrainResults { + pub(crate) results: Vec, + pub(crate) disconnected: bool, +} + +fn same_job_identity(left: &EditHighlightJob, right: &EditHighlightJob) -> bool { + left.job_id == right.job_id + && left.tool_id == right.tool_id + && left.message_index == right.message_index + && left.message_revision == right.message_revision + && left.syntax_theme_revision == right.syntax_theme_revision + && left.syntax_theme == right.syntax_theme + && left.syntax_color_level == right.syntax_color_level + && left.absolute_path == right.absolute_path + && left.display_path == right.display_path + && left.parsed == right.parsed +} + +fn coalesce_jobs( + first: EditHighlightJob, + queued: impl IntoIterator, +) -> Vec { + let mut positions = HashMap::new(); + let mut jobs = Vec::new(); + + positions.insert(first.tool_id.clone(), 0); + jobs.push(first); + + for job in queued { + if let Some(position) = positions.get(&job.tool_id).copied() { + jobs[position] = job; + } else { + positions.insert(job.tool_id.clone(), jobs.len()); + jobs.push(job); + } + } + + jobs +} + +fn read_capped_utf8_from(reader: impl Read, is_file: bool, metadata_len: u64) -> Option { + if !is_file || metadata_len > MAX_HIGHLIGHT_BYTES as u64 { + return None; + } + + let mut bytes = Vec::with_capacity(metadata_len as usize); + reader + .take(MAX_HIGHLIGHT_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .ok()?; + if bytes.len() > MAX_HIGHLIGHT_BYTES { + return None; + } + + let text = String::from_utf8(bytes).ok()?; + content_within_limits(&text).then_some(text) +} + +fn open_highlight_file(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + } + options.open(path) +} + +fn read_capped_utf8_with( + path: &Path, + open: impl FnOnce(&Path) -> io::Result, +) -> Option { + let file = open(path).ok()?; + let metadata = file.metadata().ok()?; + read_capped_utf8_from(file, metadata.is_file(), metadata.len()) +} + +fn read_capped_utf8(path: &Path) -> Option { + read_capped_utf8_with(path, open_highlight_file) +} + +fn run_job(job: &EditHighlightJob) -> EditHighlightOutcome { + let Some(file_text) = read_capped_utf8(&job.absolute_path) else { + return EditHighlightOutcome::Failed; + }; + compute_parsed_diff_file_scoped_styles( + Path::new(&job.display_path), + &file_text, + &job.parsed, + job.syntax_theme, + job.syntax_color_level, + ) + .map(|styles| EditHighlightOutcome::Ready { + styles: Arc::new(styles), + }) + .unwrap_or(EditHighlightOutcome::Failed) +} + +fn worker_loop(job_rx: Receiver, result_tx: Sender) { + while let Ok(first) = job_rx.recv() { + for job in coalesce_jobs(first, job_rx.try_iter()) { + let outcome = run_job(&job); + if result_tx + .send(EditHighlightResult { job, outcome }) + .is_err() + { + return; + } + } + } +} + +fn spawn_worker() -> io::Result<( + Sender, + Receiver, + JoinHandle<()>, +)> { + spawn_worker_with_runner(worker_loop) +} + +fn spawn_worker_with_runner( + runner: impl FnOnce(Receiver, Sender) + Send + 'static, +) -> io::Result<( + Sender, + Receiver, + JoinHandle<()>, +)> { + spawn_worker_with(runner, |worker| { + thread::Builder::new() + .name("orca-edit-highlight".to_owned()) + .spawn(worker) + }) +} + +fn spawn_worker_with( + runner: impl FnOnce(Receiver, Sender) + Send + 'static, + spawner: impl FnOnce(Box) -> io::Result>, +) -> io::Result<( + Sender, + Receiver, + JoinHandle<()>, +)> { + let (job_tx, job_rx) = crossbeam_channel::unbounded(); + let (result_tx, result_rx) = crossbeam_channel::unbounded(); + let worker = spawner(Box::new(move || runner(job_rx, result_tx)))?; + Ok((job_tx, result_rx, worker)) +} + +pub(crate) struct EditHighlightRuntime { + job_tx: Sender, + result_rx: Receiver, + pending: HashMap, + next_job_id: u64, + #[cfg(test)] + successful_submit_count: usize, +} + +impl EditHighlightRuntime { + pub(crate) fn new() -> io::Result { + Self::new_with_channels(spawn_worker()?) + } + + fn new_with_channels( + (job_tx, result_rx, _worker): ( + Sender, + Receiver, + JoinHandle<()>, + ), + ) -> io::Result { + Ok(Self { + job_tx, + result_rx, + pending: HashMap::new(), + next_job_id: 1, + #[cfg(test)] + successful_submit_count: 0, + }) + } + + #[cfg(test)] + fn new_with_worker( + runner: impl FnOnce(Receiver, Sender) + Send + 'static, + ) -> io::Result { + Self::new_with_channels(spawn_worker_with_runner(runner)?) + } + + #[cfg(test)] + fn new_with_spawner( + spawner: impl FnOnce(Box) -> io::Result>, + ) -> io::Result { + Self::new_with_channels(spawn_worker_with(worker_loop, spawner)?) + } + + pub(crate) fn allocate_job_id(&mut self) -> u64 { + let job_id = self.next_job_id.max(1); + self.next_job_id = job_id.wrapping_add(1).max(1); + job_id + } + + pub(crate) fn submit(&mut self, job: EditHighlightJob) -> bool { + if self.job_tx.send(job.clone()).is_err() { + self.pending.clear(); + return false; + } + #[cfg(test)] + { + self.successful_submit_count = self.successful_submit_count.saturating_add(1); + } + self.pending.insert(job.tool_id.clone(), job); + true + } + + pub(crate) fn drain_results(&mut self) -> DrainResults { + let mut results = Vec::new(); + let disconnected = loop { + match self.result_rx.try_recv() { + Ok(result) => results.push(result), + Err(TryRecvError::Empty) => break false, + Err(TryRecvError::Disconnected) => break true, + } + }; + if disconnected { + self.pending.retain(|_, pending| { + results + .iter() + .any(|result| same_job_identity(pending, &result.job)) + }); + } + DrainResults { + results, + disconnected, + } + } + + pub(crate) fn has_pending(&self) -> bool { + !self.pending.is_empty() + } + + pub(crate) fn pending_count(&self) -> usize { + self.pending.len() + } + + pub(crate) fn pending_matches(&self, job: &EditHighlightJob) -> bool { + self.pending + .get(&job.tool_id) + .is_some_and(|pending| same_job_identity(pending, job)) + } + + pub(crate) fn finish_pending(&mut self, job: &EditHighlightJob) -> bool { + if !self.pending_matches(job) { + return false; + } + self.pending.remove(&job.tool_id); + true + } + + pub(crate) fn clear_pending(&mut self) { + self.pending.clear(); + } + + pub(crate) fn cancel_pending_for_message( + &mut self, + message_index: usize, + message_revision: u64, + ) -> bool { + let before = self.pending.len(); + self.pending.retain(|_, pending| { + pending.message_index != message_index || pending.message_revision != message_revision + }); + self.pending.len() != before + } + + #[cfg(test)] + pub(crate) fn pending_job(&self, tool_id: &str) -> Option { + self.pending.get(tool_id).cloned() + } + + #[cfg(test)] + pub(crate) fn successful_submit_count(&self) -> usize { + self.successful_submit_count + } + + #[cfg(test)] + pub(crate) fn disconnected_for_test() -> Self { + let (job_tx, job_rx) = crossbeam_channel::unbounded(); + drop(job_rx); + let (_result_tx, result_rx) = crossbeam_channel::unbounded(); + Self { + job_tx, + result_rx, + pending: HashMap::new(), + next_job_id: 1, + successful_submit_count: 0, + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::io::{self, Read}; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use crossbeam_channel::RecvTimeoutError; + use ratatui::text::Span; + + #[cfg(unix)] + use std::io::Write; + #[cfg(unix)] + use std::process::Command; + + use super::{ + DrainResults, EditHighlightJob, EditHighlightOutcome, EditHighlightResult, + EditHighlightRuntime, coalesce_jobs, read_capped_utf8_from, read_capped_utf8_with, run_job, + spawn_worker, + }; + use crate::diff_highlight::{RefinedDiffStyles, parse_unified_diff}; + use crate::syntax_highlight::{ + MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINE_BYTES, MAX_HIGHLIGHT_LINES, SyntaxTheme, + highlighter_for_path, + }; + use crate::terminal_capabilities::{TerminalColorLevel, syntax_style_revision}; + + const MATCHING_DIFF: &str = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1 @@ +-value: int = 1 ++value: int = 42 +"; + + fn job( + job_id: u64, + tool_id: &str, + absolute_path: PathBuf, + display_path: &str, + diff: &str, + ) -> EditHighlightJob { + EditHighlightJob { + job_id, + tool_id: tool_id.to_owned(), + message_index: 2, + message_revision: 7, + syntax_theme_revision: syntax_style_revision( + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ), + syntax_theme: SyntaxTheme::OneHalfDark, + syntax_color_level: TerminalColorLevel::TrueColor, + absolute_path, + display_path: display_path.to_owned(), + parsed: parse_unified_diff(diff), + } + } + + fn assert_same_job(actual: &EditHighlightJob, expected: &EditHighlightJob) { + assert_eq!(actual.job_id, expected.job_id); + assert_eq!(actual.tool_id, expected.tool_id); + assert_eq!(actual.message_index, expected.message_index); + assert_eq!(actual.message_revision, expected.message_revision); + assert_eq!(actual.syntax_theme_revision, expected.syntax_theme_revision); + assert_eq!(actual.syntax_theme, expected.syntax_theme); + assert_eq!(actual.syntax_color_level, expected.syntax_color_level); + assert_eq!(actual.absolute_path, expected.absolute_path); + assert_eq!(actual.display_path, expected.display_path); + assert_eq!(actual.parsed, expected.parsed); + } + + fn ready_styles(outcome: EditHighlightOutcome) -> Arc { + match outcome { + EditHighlightOutcome::Ready { styles } => styles, + EditHighlightOutcome::Failed => panic!("expected ready highlight result"), + } + } + + fn assert_failed(job: &EditHighlightJob) { + assert!(matches!(run_job(job), EditHighlightOutcome::Failed)); + } + + fn exact_byte_limit_text() -> String { + let mut text = String::from("value: int = 42\n"); + let full_line = format!("{}\n", "x".repeat(MAX_HIGHLIGHT_LINE_BYTES - 1)); + while text.len() + full_line.len() <= MAX_HIGHLIGHT_BYTES { + text.push_str(&full_line); + } + text.push_str(&"x".repeat(MAX_HIGHLIGHT_BYTES - text.len())); + assert_eq!(text.len(), MAX_HIGHLIGHT_BYTES); + assert!(text.lines().count() < MAX_HIGHLIGHT_LINES); + assert!( + text.lines() + .all(|line| line.len() <= MAX_HIGHLIGHT_LINE_BYTES) + ); + text + } + + struct CountingReader { + remaining: usize, + bytes_read: Arc, + } + + impl Read for CountingReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let count = buffer.len().min(self.remaining); + buffer[..count].fill(b'x'); + self.remaining -= count; + self.bytes_read.fetch_add(count, Ordering::SeqCst); + Ok(count) + } + } + + struct PanicReader; + + impl Read for PanicReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + panic!("metadata guard must reject before reading the handle") + } + } + + #[test] + fn coalescing_keeps_latest_full_job_in_original_fifo_key_order() { + let first = job(1, "edit-a", PathBuf::from("/first-a"), "first-a.py", ""); + let mut latest_a = job( + 2, + "edit-a", + PathBuf::from("/latest-a"), + "latest-a.rs", + "--- a/latest-a.rs\n+++ b/latest-a.rs\n@@ -1 +1 @@\n-old\n+new\n", + ); + latest_a.message_index = 12; + latest_a.message_revision = 17; + latest_a.syntax_theme = SyntaxTheme::OneHalfLight; + latest_a.syntax_color_level = TerminalColorLevel::Ansi256; + latest_a.syntax_theme_revision = + syntax_style_revision(SyntaxTheme::OneHalfLight, latest_a.syntax_color_level); + let latest_b = job( + 3, + "edit-b", + PathBuf::from("/latest-b"), + "latest-b.py", + MATCHING_DIFF, + ); + + let coalesced = coalesce_jobs(first, [latest_a.clone(), latest_b.clone()]); + + assert_eq!(coalesced.len(), 2); + assert_same_job(&coalesced[0], &latest_a); + assert_same_job(&coalesced[1], &latest_b); + } + + #[test] + fn coalescing_replacement_after_another_key_keeps_original_key_position() { + let first = job(1, "edit-a", PathBuf::from("/a1"), "a1.py", ""); + let middle_b = job(3, "edit-b", PathBuf::from("/b"), "b.py", ""); + let latest_a = job(4, "edit-a", PathBuf::from("/a4"), "a4.py", MATCHING_DIFF); + + let coalesced = coalesce_jobs( + first, + [ + job(2, "edit-a", PathBuf::from("/a2"), "a2.py", ""), + middle_b.clone(), + latest_a.clone(), + ], + ); + + assert_eq!(coalesced.len(), 2); + assert_same_job(&coalesced[0], &latest_a); + assert_same_job(&coalesced[1], &middle_b); + } + + #[test] + fn run_job_returns_exact_ready_style_map_for_matching_python_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("item.py"); + std::fs::write(&path, "value: int = 42\n").unwrap(); + let job = job(1, "edit-a", path, "src/item.py", MATCHING_DIFF); + + let styles = ready_styles(run_job(&job)); + let mut highlighter = highlighter_for_path( + Path::new("src/item.py"), + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .expect("known Python syntax"); + let expected = highlighter + .highlight_line("value: int = 42") + .expect("highlighted Python line"); + + assert_eq!(styles.len(), 1); + assert_eq!(styles.get(&1), Some(&expected)); + assert_eq!( + styles[&1] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "value: int = 42" + ); + } + + #[test] + fn run_job_fails_for_missing_file() { + let directory = tempfile::tempdir().unwrap(); + assert_failed(&job( + 1, + "missing", + directory.path().join("missing.py"), + "src/item.py", + MATCHING_DIFF, + )); + } + + #[test] + fn run_job_fails_for_non_file_path() { + let directory = tempfile::tempdir().unwrap(); + assert_failed(&job( + 1, + "directory", + directory.path().to_path_buf(), + "src/item.py", + MATCHING_DIFF, + )); + } + + #[cfg(unix)] + #[test] + fn run_job_rejects_final_component_symlink() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.py"); + let link = directory.path().join("item.py"); + std::fs::write(&target, "value: int = 42\n").unwrap(); + symlink(&target, &link).unwrap(); + + assert_failed(&job(1, "symlink", link, "src/item.py", MATCHING_DIFF)); + } + + #[test] + fn run_job_fails_for_non_utf8_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("binary.py"); + std::fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap(); + + assert_failed(&job(1, "binary", path, "src/item.py", MATCHING_DIFF)); + } + + #[test] + fn metadata_length_above_cap_fails_before_file_read() { + let result = read_capped_utf8_from(PanicReader, true, MAX_HIGHLIGHT_BYTES as u64 + 1); + + assert!(result.is_none()); + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("too-large.py"); + let mut text = exact_byte_limit_text(); + text.push('x'); + std::fs::write(&path, text).unwrap(); + assert_failed(&job(1, "too-large", path, "src/item.py", MATCHING_DIFF)); + } + + #[test] + fn bounded_reader_rejects_growth_after_cap_plus_one_bytes() { + let bytes_read = Arc::new(AtomicUsize::new(0)); + let reader = CountingReader { + remaining: MAX_HIGHLIGHT_BYTES + 128, + bytes_read: Arc::clone(&bytes_read), + }; + + let result = read_capped_utf8_from(reader, true, MAX_HIGHLIGHT_BYTES as u64); + + assert!(result.is_none()); + assert_eq!(bytes_read.load(Ordering::SeqCst), MAX_HIGHLIGHT_BYTES + 1); + } + + #[cfg(unix)] + #[test] + fn opened_regular_handle_survives_path_replacement_with_fifo() { + use std::os::unix::fs::FileTypeExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("item.py"); + std::fs::write(&path, "value: int = 42\n").unwrap(); + + let text = read_capped_utf8_with(&path, |path| { + let file = std::fs::File::open(path)?; + std::fs::remove_file(path)?; + let status = Command::new("mkfifo").arg(path).status()?; + if !status.success() { + return Err(io::Error::other("mkfifo failed")); + } + Ok(file) + }); + + assert_eq!(text.as_deref(), Some("value: int = 42\n")); + assert!(std::fs::metadata(&path).unwrap().file_type().is_fifo()); + } + + #[test] + fn run_job_fails_for_more_than_max_actual_lines_without_trailing_newline() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("too-many-lines.py"); + let mut text = String::from("value: int = 42\n"); + text.push_str(&"x\n".repeat(MAX_HIGHLIGHT_LINES - 1)); + text.push('x'); + assert_eq!(text.lines().count(), MAX_HIGHLIGHT_LINES + 1); + assert!(text.len() < MAX_HIGHLIGHT_BYTES); + std::fs::write(&path, text).unwrap(); + + assert_failed(&job( + 1, + "too-many-lines", + path, + "src/item.py", + MATCHING_DIFF, + )); + } + + #[test] + fn run_job_fails_for_matching_line_above_line_byte_cap() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("long-line.py"); + let source = "x".repeat(MAX_HIGHLIGHT_LINE_BYTES + 1); + std::fs::write(&path, &source).unwrap(); + let diff = format!("--- /dev/null\n+++ b/long-line.py\n@@ -0,0 +1 @@\n+{source}\n"); + + assert_failed(&job(1, "long-line", path, "long-line.py", &diff)); + } + + #[test] + fn run_job_fails_when_post_edit_text_drifted_from_diff() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("item.py"); + std::fs::write(&path, "value: int = 43\n").unwrap(); + + assert_failed(&job(1, "drifted", path, "src/item.py", MATCHING_DIFF)); + } + + #[test] + fn run_job_fails_for_unknown_syntax_with_matching_text() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("item.unknown"); + std::fs::write(&path, "value: int = 42\n").unwrap(); + + assert_failed(&job(1, "unknown", path, "src/item.unknown", MATCHING_DIFF)); + } + + #[test] + fn run_job_fails_for_multi_file_parsed_diff_with_matching_text() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("item.py"); + std::fs::write(&path, "value: int = 42\n").unwrap(); + let diff = "\ +--- a/first.py ++++ b/first.py +@@ -1 +1 @@ +-value: int = 1 ++value: int = 42 +--- a/second.py ++++ b/second.py +@@ -1 +1 @@ +-value: int = 1 ++value: int = 42 +"; + + let job = job(1, "multi-file", path, "src/item.py", diff); + assert!(job.parsed.has_multiple_files); + assert_failed(&job); + } + + #[test] + fn run_job_accepts_exact_byte_line_count_and_line_length_boundaries() { + let directory = tempfile::tempdir().unwrap(); + + let exact_bytes_path = directory.path().join("exact-bytes.py"); + std::fs::write(&exact_bytes_path, exact_byte_limit_text()).unwrap(); + let exact_bytes = job( + 1, + "exact-bytes", + exact_bytes_path, + "src/item.py", + MATCHING_DIFF, + ); + + let exact_lines_path = directory.path().join("exact-lines.py"); + let mut exact_lines_text = String::from("value: int = 42\n"); + exact_lines_text.push_str(&"x\n".repeat(MAX_HIGHLIGHT_LINES - 1)); + assert_eq!(exact_lines_text.lines().count(), MAX_HIGHLIGHT_LINES); + std::fs::write(&exact_lines_path, exact_lines_text).unwrap(); + let exact_lines = job( + 2, + "exact-lines", + exact_lines_path, + "src/item.py", + MATCHING_DIFF, + ); + + let exact_line_path = directory.path().join("exact-line.py"); + let exact_line_source = "x".repeat(MAX_HIGHLIGHT_LINE_BYTES); + std::fs::write(&exact_line_path, &exact_line_source).unwrap(); + let exact_line_diff = + format!("--- /dev/null\n+++ b/exact-line.py\n@@ -0,0 +1 @@\n+{exact_line_source}\n"); + let exact_line = job( + 3, + "exact-line", + exact_line_path, + "exact-line.py", + &exact_line_diff, + ); + + for boundary_job in [exact_bytes, exact_lines, exact_line] { + assert!( + matches!(run_job(&boundary_job), EditHighlightOutcome::Ready { .. }), + "exact boundary rejected for {}", + boundary_job.tool_id + ); + } + } + + #[test] + fn delete_only_diff_returns_ready_with_empty_styles_for_existing_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("item.py"); + std::fs::write(&path, "").unwrap(); + let diff = "\ +--- a/item.py ++++ b/item.py +@@ -1 +0,0 @@ +-value = 1 +"; + + let styles = ready_styles(run_job(&job(1, "delete-only", path, "item.py", diff))); + + assert!(styles.is_empty()); + } + + #[cfg(unix)] + #[test] + fn worker_rejects_fifo_without_blocking_the_only_worker() { + use std::os::unix::fs::OpenOptionsExt; + + let directory = tempfile::tempdir().unwrap(); + let fifo = directory.path().join("item.py"); + let status = Command::new("mkfifo") + .arg(&fifo) + .status() + .expect("run mkfifo"); + assert!(status.success()); + let submitted = job(40, "fifo", fifo.clone(), "src/item.py", MATCHING_DIFF); + let (job_tx, result_rx, worker) = spawn_worker().expect("test worker"); + + job_tx.send(submitted).unwrap(); + let result = match result_rx.recv_timeout(Duration::from_secs(1)) { + Ok(result) => result, + Err(RecvTimeoutError::Timeout) => { + let mut fifo_guard = std::fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(&fifo) + .expect("open nonblocking FIFO cleanup guard"); + fifo_guard.write_all(b"x").unwrap(); + drop(fifo_guard); + let _ = result_rx.recv_timeout(Duration::from_secs(1)); + drop(job_tx); + worker.join().expect("blocked worker cleanup"); + panic!("FIFO blocked the edit highlight worker"); + } + Err(RecvTimeoutError::Disconnected) => panic!("worker disconnected without a result"), + }; + + assert!(matches!(result.outcome, EditHighlightOutcome::Failed)); + drop(job_tx); + worker.join().expect("worker shutdown"); + } + + #[test] + fn named_worker_thread_returns_one_owned_result_with_same_job_identity() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("item.py"); + std::fs::write(&path, "value: int = 42\n").unwrap(); + let expected = job(41, "edit-a", path, "src/item.py", MATCHING_DIFF); + let (job_tx, result_rx, worker) = spawn_worker().expect("test worker"); + + assert_eq!(worker.thread().name(), Some("orca-edit-highlight")); + job_tx.send(expected.clone()).unwrap(); + let result = result_rx + .recv_timeout(Duration::from_secs(2)) + .expect("worker result"); + + assert_same_job(&result.job, &expected); + assert!(matches!(result.outcome, EditHighlightOutcome::Ready { .. })); + drop(job_tx); + worker.join().expect("worker shutdown"); + } + + #[test] + fn worker_exits_when_job_channel_disconnects() { + let (job_tx, result_rx, worker) = spawn_worker().expect("test worker"); + + drop(job_tx); + worker.join().expect("worker shutdown"); + + assert!(matches!( + result_rx.recv_timeout(Duration::from_millis(50)), + Err(RecvTimeoutError::Disconnected) + )); + } + + #[test] + fn worker_exits_when_result_receiver_disconnects() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("item.py"); + std::fs::write(&path, "value: int = 42\n").unwrap(); + let submitted = job(51, "edit-a", path, "src/item.py", MATCHING_DIFF); + let (job_tx, result_rx, worker) = spawn_worker().expect("test worker"); + + drop(result_rx); + job_tx.send(submitted).unwrap(); + worker.join().expect("worker shutdown"); + } + + #[test] + fn runtime_pending_replacement_and_finish_use_full_job_identity() { + let mut runtime = EditHighlightRuntime::new().expect("test runtime"); + let first_a = job(1, "edit-a", PathBuf::from("/a1"), "a1.py", ""); + let latest_a = job(2, "edit-a", PathBuf::from("/a2"), "a2.py", MATCHING_DIFF); + let b = job(3, "edit-b", PathBuf::from("/b"), "b.py", ""); + + assert!(runtime.submit(first_a)); + assert!(runtime.submit(b.clone())); + assert!(runtime.submit(latest_a.clone())); + + assert!(runtime.has_pending()); + assert_eq!(runtime.pending_count(), 2); + assert_same_job( + &runtime.pending_job("edit-a").expect("latest pending A"), + &latest_a, + ); + assert!(runtime.pending_matches(&latest_a)); + + let mut stale_jobs = Vec::new(); + let mut stale = latest_a.clone(); + stale.tool_id = "edit-c".to_owned(); + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.job_id += 1; + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.message_index += 1; + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.message_revision += 1; + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.syntax_theme_revision = + syntax_style_revision(SyntaxTheme::OneHalfLight, stale.syntax_color_level); + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.syntax_theme = SyntaxTheme::OneHalfLight; + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.syntax_color_level = TerminalColorLevel::Ansi16; + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.absolute_path = PathBuf::from("/other"); + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.display_path = "other.py".to_owned(); + stale_jobs.push(stale); + let mut stale = latest_a.clone(); + stale.parsed = + parse_unified_diff("--- a/a2.py\n+++ b/a2.py\n@@ -1 +1 @@\n-old\n+different\n"); + stale_jobs.push(stale); + + for stale in stale_jobs { + assert!(!runtime.pending_matches(&stale)); + assert!(!runtime.finish_pending(&stale)); + } + + assert!(runtime.finish_pending(&latest_a)); + assert!(!runtime.pending_matches(&latest_a)); + assert_eq!(runtime.pending_count(), 1); + assert!(runtime.finish_pending(&b)); + assert!(!runtime.has_pending()); + runtime.clear_pending(); + assert_eq!(runtime.pending_count(), 0); + } + + #[test] + fn cancel_pending_for_message_removes_only_exact_message_revision() { + let mut runtime = EditHighlightRuntime::new().expect("test runtime"); + let a = job(1, "edit-a", PathBuf::from("/a"), "a.py", ""); + let mut b = job(2, "edit-b", PathBuf::from("/b"), "b.py", ""); + b.message_index += 1; + b.message_revision += 1; + assert!(runtime.submit(a.clone())); + assert!(runtime.submit(b.clone())); + + assert!(!runtime.cancel_pending_for_message(a.message_index, a.message_revision + 1)); + assert!(runtime.pending_matches(&a)); + assert!(runtime.pending_matches(&b)); + + assert!(runtime.cancel_pending_for_message(a.message_index, a.message_revision)); + assert!(!runtime.pending_matches(&a)); + assert!(runtime.pending_matches(&b)); + assert_eq!(runtime.pending_count(), 1); + } + + #[test] + fn allocate_job_id_wraps_from_max_to_one_without_returning_zero() { + let mut runtime = EditHighlightRuntime::new().expect("test runtime"); + runtime.next_job_id = u64::MAX; + + assert_eq!(runtime.allocate_job_id(), u64::MAX); + assert_eq!(runtime.allocate_job_id(), 1); + assert_eq!(runtime.allocate_job_id(), 2); + } + + #[test] + fn failed_submit_clears_pending_and_does_not_insert_failed_job() { + let (job_tx, job_rx) = crossbeam_channel::unbounded(); + drop(job_rx); + let (_result_tx, result_rx) = crossbeam_channel::unbounded(); + let existing = job(1, "existing", PathBuf::from("/old"), "old.py", ""); + let mut runtime = EditHighlightRuntime { + job_tx, + result_rx, + pending: HashMap::from([(existing.tool_id.clone(), existing)]), + next_job_id: 2, + successful_submit_count: 0, + }; + + assert!(!runtime.submit(job(2, "failed", PathBuf::from("/new"), "new.py", ""))); + assert!(!runtime.has_pending()); + assert_eq!(runtime.pending_count(), 0); + assert!(runtime.pending_job("failed").is_none()); + } + + #[test] + fn successful_submit_count_tracks_sends_not_pending_replacements() { + let mut runtime = EditHighlightRuntime::new().expect("test runtime"); + + assert!(runtime.submit(job(1, "same-tool", PathBuf::from("/first"), "first.py", ""))); + assert!(runtime.submit(job( + 2, + "same-tool", + PathBuf::from("/second"), + "second.py", + "" + ))); + + assert_eq!(runtime.pending_count(), 1); + assert_eq!(runtime.successful_submit_count(), 2); + } + + #[test] + fn disconnected_runtime_for_test_rejects_submit_without_counting_success() { + let mut runtime = EditHighlightRuntime::disconnected_for_test(); + + assert!(!runtime.submit(job( + 1, + "disconnected", + PathBuf::from("/item"), + "item.py", + "" + ))); + assert_eq!(runtime.pending_count(), 0); + assert_eq!(runtime.successful_submit_count(), 0); + } + + #[test] + fn spawn_failure_returns_error_without_constructing_a_runtime() { + let error = match EditHighlightRuntime::new_with_spawner(|_| { + Err(io::Error::other("injected spawn failure")) + }) { + Ok(_) => panic!("spawn failure must not construct a runtime"), + Err(error) => error, + }; + + assert_eq!(error.kind(), io::ErrorKind::Other); + assert_eq!(error.to_string(), "injected spawn failure"); + } + + #[test] + fn accepted_job_then_worker_exit_reports_disconnect_and_clears_pending() { + let (accepted_tx, accepted_rx) = crossbeam_channel::bounded(1); + let (exited_tx, exited_rx) = crossbeam_channel::bounded(1); + let mut runtime = EditHighlightRuntime::new_with_worker(move |job_rx, result_tx| { + let accepted = job_rx.recv().expect("accepted job"); + accepted_tx.send(accepted).unwrap(); + drop(result_tx); + exited_tx.send(()).unwrap(); + }) + .expect("test runtime"); + let submitted = job(1, "stranded", PathBuf::from("/old"), "old.py", ""); + + assert!(runtime.submit(submitted.clone())); + let accepted = accepted_rx + .recv_timeout(Duration::from_secs(1)) + .expect("worker accepted job"); + assert_same_job(&accepted, &submitted); + exited_rx + .recv_timeout(Duration::from_secs(1)) + .expect("worker exited"); + + let DrainResults { + results, + disconnected, + } = runtime.drain_results(); + + assert!(results.is_empty()); + assert!(disconnected); + assert!(!runtime.has_pending()); + assert_eq!(runtime.pending_count(), 0); + } + + #[test] + fn drain_results_returns_owned_nonblocking_vec() { + let (job_tx, _job_rx) = crossbeam_channel::unbounded(); + let (result_tx, result_rx) = crossbeam_channel::unbounded(); + let mut runtime = EditHighlightRuntime { + job_tx, + result_rx, + pending: HashMap::new(), + next_job_id: 1, + successful_submit_count: 0, + }; + + let empty = runtime.drain_results(); + assert!(empty.results.is_empty()); + assert!(!empty.disconnected); + + for job_id in [1, 2] { + let result_job = job( + job_id, + &format!("edit-{job_id}"), + PathBuf::from(format!("/{job_id}")), + "item.py", + "", + ); + result_tx + .send(EditHighlightResult { + job: result_job, + outcome: EditHighlightOutcome::Failed, + }) + .unwrap(); + } + + let drained = runtime.drain_results(); + runtime.clear_pending(); + + assert!(!drained.disconnected); + assert_eq!( + drained + .results + .iter() + .map(|result| result.job.job_id) + .collect::>(), + vec![1, 2] + ); + assert!(runtime.drain_results().results.is_empty()); + } + + #[test] + fn ready_result_contract_owns_arc_style_map() { + let styles = Arc::new(RefinedDiffStyles::from([( + 1, + vec![Span::raw("value: int = 42".to_owned())], + )])); + let result = EditHighlightResult { + job: job( + 1, + "edit-a", + PathBuf::from("/item.py"), + "item.py", + MATCHING_DIFF, + ), + outcome: EditHighlightOutcome::Ready { + styles: Arc::clone(&styles), + }, + }; + + let EditHighlightOutcome::Ready { + styles: result_styles, + } = result.outcome + else { + panic!("ready result"); + }; + assert!(Arc::ptr_eq(&styles, &result_styles)); + } +} diff --git a/crates/orca-tui/src/global_actions.rs b/crates/orca-tui/src/global_actions.rs index ce0ee08b..e51ac114 100644 --- a/crates/orca-tui/src/global_actions.rs +++ b/crates/orca-tui/src/global_actions.rs @@ -30,6 +30,7 @@ where | AppStatus::WaitingApproval | AppStatus::WaitingUserInput ) { + state.suspend_queued_follow_up_autosend(); operation.interrupt_current(); let _ = action_tx.send(UserAction::Interrupt); return Ok(GlobalShortcutFlow::Continue); @@ -46,6 +47,9 @@ where state.push_message(ChatMessage::System("Press Ctrl+C again to quit.".into())); state.scroll_to_bottom(); } + GlobalShortcut::OpenTranscriptSearch => { + state.open_transcript_search(); + } GlobalShortcut::ToggleShortcuts => { state.toggle_shortcuts(); } @@ -97,6 +101,7 @@ mod tests { assert_eq!(operation.call_count(), 1); assert!(matches!(action_rx.try_recv(), Ok(UserAction::Interrupt))); + assert!(!state.queued_follow_up_autosend); } #[test] diff --git a/crates/orca-tui/src/hosted_runtime.rs b/crates/orca-tui/src/hosted_runtime.rs index d54237c1..c31999b6 100644 --- a/crates/orca-tui/src/hosted_runtime.rs +++ b/crates/orca-tui/src/hosted_runtime.rs @@ -16,19 +16,26 @@ pub(crate) enum TuiHostedOperationOutcome { pub(crate) struct TuiHostedEventObserver { event_tx: Sender, + queued_id: Option, state: Mutex, } #[derive(Default)] struct TuiHostedEventObserverState { foreground_finished: bool, + queued_submission_started: bool, terminal_event: Option, } impl TuiHostedEventObserver { pub(crate) fn new(event_tx: Sender) -> Self { + Self::new_with_queued_id(event_tx, None) + } + + pub(crate) fn new_with_queued_id(event_tx: Sender, queued_id: Option) -> Self { Self { event_tx, + queued_id, state: Mutex::new(TuiHostedEventObserverState::default()), } } @@ -49,6 +56,13 @@ impl TuiHostedEventObserver { Ok(false) } + pub(crate) fn queued_submission_started(&self) -> bool { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .queued_submission_started + } + fn send(&self, event: TuiEvent) -> io::Result<()> { self.event_tx.send(event).map_err(|_| { io::Error::new( @@ -72,7 +86,16 @@ impl EventObserver for TuiHostedEventObserver { state.terminal_event = Some(event); return Ok(()); } - drop(state); + if matches!(event, TuiEvent::TurnStarted { .. }) + && let Some(id) = self.queued_id + && !state.queued_submission_started + { + state.queued_submission_started = true; + drop(state); + self.send(TuiEvent::QueuedSubmissionStarted { id })?; + } else { + drop(state); + } let notice = background_task_notice_from_event(&event); self.send(event)?; if let Some(notice) = notice { @@ -179,4 +202,42 @@ mod tests { Ok(TuiEvent::SessionCompleted { status }) if status == "cancelled" )); } + + #[test] + fn hosted_observer_acknowledges_queued_id_only_at_runtime_turn_start() { + let (event_tx, event_rx) = crossbeam_channel::unbounded(); + let observer = TuiHostedEventObserver::new_with_queued_id(event_tx, Some(42)); + let mut events = EventFactory::new("queued-turn-start".to_string()); + let turn_id = orca_core::thread_identity::TurnId::new(); + + assert!(!observer.queued_submission_started()); + observe_event( + Some(&observer), + events.turn_started(&turn_id, 1, Some("expanded prompt")), + ) + .unwrap(); + + assert!(observer.queued_submission_started()); + assert!(matches!( + event_rx.try_recv(), + Ok(TuiEvent::QueuedSubmissionStarted { id: 42 }) + )); + assert!(matches!( + event_rx.try_recv(), + Ok(TuiEvent::TurnStarted { turn: 1, .. }) + )); + + let next_turn_id = orca_core::thread_identity::TurnId::new(); + observe_event( + Some(&observer), + events.turn_started(&next_turn_id, 2, Some("automatic continuation")), + ) + .unwrap(); + + assert!(matches!( + event_rx.try_recv(), + Ok(TuiEvent::TurnStarted { turn: 2, .. }) + )); + assert!(event_rx.try_recv().is_err()); + } } diff --git a/crates/orca-tui/src/idle_key_actions.rs b/crates/orca-tui/src/idle_key_actions.rs index cad54359..98db31e6 100644 --- a/crates/orca-tui/src/idle_key_actions.rs +++ b/crates/orca-tui/src/idle_key_actions.rs @@ -9,9 +9,13 @@ use orca_core::config::RunConfig; use crate::composer_input_actions::{ apply_composer_key_input, insert_composer_newline, recall_next_history, recall_previous_history, }; +use crate::composer_textarea::{make_textarea_with_text, textarea_text}; use crate::idle_navigation_actions::handle_idle_navigation_shortcut; use crate::idle_submit_actions::handle_idle_submit; +use crate::keybindings::{InputOwnerFingerprint, KeymapRuntime, ShortcutResolution}; +use crate::keybindings::{InvocationOrigin, ShortcutInvocation}; use crate::mention_menu_actions::handle_mention_menu_key; +use crate::queued_input_actions::restore_latest_queued_message; use crate::shortcuts::{IdleShortcut, ShortcutAction, ShortcutContext, resolve_shortcut}; use crate::slash_menu_actions::handle_slash_menu_key; use crate::theme::Theme; @@ -44,6 +48,7 @@ pub(crate) fn handle_idle_key( theme, ) { + vim_state.cancel_pending_command(); return; } @@ -51,15 +56,116 @@ pub(crate) fn handle_idle_key( || (state.mention.phase.is_some() && key.code == KeyCode::Esc)) && handle_mention_menu_key(ev, key, state, textarea, vim_state, theme) { + vim_state.cancel_pending_command(); return; } if handle_workflows_panel_key(key.code, state, action_tx) { + vim_state.cancel_pending_command(); return; } - match resolve_shortcut(ShortcutContext::Idle, *key) { - Some(ShortcutAction::Idle(IdleShortcut::Submit)) => { + if let Some(action @ ShortcutAction::Idle(_)) = resolve_shortcut(ShortcutContext::Idle, *key) { + handle_idle_shortcut_invocation( + ShortcutInvocation::key(action, *key), + state, + config, + shared_config, + action_tx, + textarea, + vim_state, + theme, + ); + } else { + apply_composer_key_input(ev, key, state, config, textarea, vim_state, theme); + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_idle_key_dynamic( + ev: &Event, + key: &KeyEvent, + now: std::time::Instant, + owner: InputOwnerFingerprint, + keymap: &mut KeymapRuntime, + state: &mut AppState, + config: &mut RunConfig, + shared_config: &Arc>, + action_tx: &mpsc::Sender, + textarea: &mut TextArea, + vim_state: &mut VimState, + theme: &Theme, +) -> bool { + if state.slash_menu.is_some() + && handle_slash_menu_key( + ev, + key, + state, + config, + shared_config, + action_tx, + textarea, + vim_state, + theme, + ) + { + vim_state.cancel_pending_command(); + return true; + } + if (!state.mention.candidates.is_empty() + || (state.mention.phase.is_some() && key.code == KeyCode::Esc)) + && handle_mention_menu_key(ev, key, state, textarea, vim_state, theme) + { + vim_state.cancel_pending_command(); + return true; + } + if handle_workflows_panel_key(key.code, state, action_tx) { + vim_state.cancel_pending_command(); + return true; + } + + match keymap.resolve_new_context(owner, *key, now) { + ShortcutResolution::Action(invocation) => handle_idle_shortcut_invocation( + invocation, + state, + config, + shared_config, + action_tx, + textarea, + vim_state, + theme, + ), + ShortcutResolution::Pending => true, + ShortcutResolution::RetryCurrentKey | ShortcutResolution::NoMatch => { + apply_composer_key_input(ev, key, state, config, textarea, vim_state, theme); + true + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_idle_shortcut_invocation( + invocation: ShortcutInvocation, + state: &mut AppState, + config: &mut RunConfig, + shared_config: &Arc>, + action_tx: &mpsc::Sender, + textarea: &mut TextArea, + vim_state: &mut VimState, + theme: &Theme, +) -> bool { + let ShortcutAction::Idle(shortcut) = invocation.action else { + return false; + }; + match shortcut { + IdleShortcut::EditLatestQueued => { + vim_state.cancel_pending_command(); + if state.status == crate::types::AppStatus::Idle { + restore_latest_queued_message(state, textarea, vim_state, theme); + } + } + IdleShortcut::Submit => { + vim_state.cancel_pending_command(); handle_idle_submit( textarea, vim_state, @@ -70,31 +176,382 @@ pub(crate) fn handle_idle_key( action_tx, ); } - Some(ShortcutAction::Idle(IdleShortcut::Newline)) => { + IdleShortcut::Newline => { + vim_state.cancel_pending_command(); insert_composer_newline(textarea, state); } - Some(ShortcutAction::Idle(IdleShortcut::HistoryPrevious)) => { - recall_previous_history(ev, key, state, textarea, vim_state, theme); + IdleShortcut::HistoryPrevious => { + vim_state.cancel_pending_command(); + match invocation.origin { + InvocationOrigin::Key(key) => recall_previous_history( + &Event::Key(key), + &key, + state, + textarea, + vim_state, + theme, + ), + InvocationOrigin::Chord => { + if let Some(history) = state.history_previous(textarea_text(textarea)) { + *textarea = make_textarea_with_text(&history, vim_state, theme); + } + } + } } - Some(ShortcutAction::Idle(IdleShortcut::HistoryNext)) => { - recall_next_history(ev, key, state, textarea, vim_state, theme); + IdleShortcut::HistoryNext => { + vim_state.cancel_pending_command(); + match invocation.origin { + InvocationOrigin::Key(key) => { + recall_next_history(&Event::Key(key), &key, state, textarea, vim_state, theme) + } + InvocationOrigin::Chord => { + if let Some(history) = state.history_next() { + *textarea = make_textarea_with_text(&history, vim_state, theme); + } + } + } } - Some(ShortcutAction::Idle( - shortcut @ (IdleShortcut::ScrollUp - | IdleShortcut::ScrollDown - | IdleShortcut::PageUp - | IdleShortcut::PageDown - | IdleShortcut::HalfPageUp - | IdleShortcut::HalfPageDown - | IdleShortcut::Backtrack - | IdleShortcut::ExpandToolOutput), - )) => { - handle_idle_navigation_shortcut( - shortcut, ev, key, state, config, textarea, vim_state, theme, action_tx, + shortcut @ (IdleShortcut::ScrollUp + | IdleShortcut::ScrollDown + | IdleShortcut::PageUp + | IdleShortcut::PageDown + | IdleShortcut::HalfPageUp + | IdleShortcut::HalfPageDown + | IdleShortcut::Backtrack + | IdleShortcut::ExpandToolOutput) => match invocation.origin { + InvocationOrigin::Key(key) => { + if shortcut != IdleShortcut::ExpandToolOutput { + vim_state.cancel_pending_command(); + } + handle_idle_navigation_shortcut( + shortcut, + &Event::Key(key), + &key, + state, + config, + textarea, + vim_state, + theme, + action_tx, + ); + } + InvocationOrigin::Chord => { + vim_state.cancel_pending_command(); + handle_idle_chord_navigation(shortcut, state, textarea, vim_state, action_tx); + } + }, + } + true +} + +fn handle_idle_chord_navigation( + shortcut: IdleShortcut, + state: &mut AppState, + textarea: &mut TextArea, + vim_state: &mut VimState, + action_tx: &mpsc::Sender, +) { + match shortcut { + IdleShortcut::ScrollUp => state.scroll_up(1), + IdleShortcut::ScrollDown => state.scroll_down(1), + IdleShortcut::PageUp => state.scroll_up(state.visible_height.saturating_sub(2)), + IdleShortcut::PageDown => state.scroll_down(state.visible_height.saturating_sub(2)), + IdleShortcut::HalfPageUp => state.scroll_up(state.visible_height / 2), + IdleShortcut::HalfPageDown => state.scroll_down(state.visible_height / 2), + IdleShortcut::Backtrack => { + let _ = action_tx.send(UserAction::Backtrack); + } + IdleShortcut::ExpandToolOutput => { + if textarea_text(textarea).trim().is_empty() && state.toggle_latest_tool_output() { + vim_state.cancel_pending_command(); + state.scroll_to_bottom(); + } + } + IdleShortcut::Submit + | IdleShortcut::Newline + | IdleShortcut::EditLatestQueued + | IdleShortcut::HistoryPrevious + | IdleShortcut::HistoryNext => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::composer_textarea::{make_textarea_with_text, textarea_text}; + use crate::keybindings::ShortcutInvocation; + use crate::test_support::test_run_config; + use crate::types::TuiEvent; + use crossterm::event::KeyModifiers; + use orca_core::config::{ThemeName, VimInsertEscapeSequence}; + + #[test] + fn nonempty_composer_keeps_vim_count_when_e_matches_expand_shortcut() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = test_run_config(); + config.vim_mode = true; + let shared = Arc::new(Mutex::new(config.clone())); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(true); + let mut textarea = TextArea::from(["one two three"]); + + for code in [KeyCode::Char('2'), KeyCode::Char('e')] { + let key = KeyEvent::new(code, KeyModifiers::NONE); + handle_idle_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &mut vim, + &theme, ); } - Some(_) | None => { - apply_composer_key_input(ev, key, state, config, textarea, vim_state, theme); + + assert_eq!(textarea.cursor(), (0, 6)); + assert!(!vim.has_pending_command_for_test()); + } + + #[test] + fn empty_multiline_composer_keeps_vim_prefix_when_expand_has_no_tool() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = test_run_config(); + config.vim_mode = true; + let shared = Arc::new(Mutex::new(config.clone())); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(true); + let mut textarea = TextArea::from([" ", " ", " "]); + + for code in [KeyCode::Char('d'), KeyCode::Char('e')] { + let key = KeyEvent::new(code, KeyModifiers::NONE); + handle_idle_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &mut vim, + &theme, + ); } + + assert_eq!(textarea.cursor(), (0, 0)); + assert!(!vim.has_pending_command_for_test()); + } + + #[test] + fn configured_first_character_does_not_steal_consumed_idle_shortcut() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.update(TuiEvent::ToolRequested { + id: "tool-1".to_string(), + name: "grep".to_string(), + target: None, + }); + let mut config = test_run_config(); + config.vim_mode = true; + config.vim_insert_escape = Some(VimInsertEscapeSequence::parse("ee").unwrap()); + let shared = Arc::new(Mutex::new(config.clone())); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::with_insert_escape(true, config.vim_insert_escape.clone()); + vim.mode = crate::vim::VimMode::Insert; + let mut textarea = TextArea::default(); + let key = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE); + + handle_idle_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &mut vim, + &theme, + ); + + assert!(textarea.is_empty()); + assert!(!vim.has_pending_insert_escape_for_test()); + let crate::types::ChatMessage::ToolCall { expanded, .. } = &state.messages[0] else { + panic!("expected tool call"); + }; + assert!(*expanded); + } + + #[test] + fn chord_expand_decline_does_not_insert_or_move_composer_text() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("first\nsecond", &vim, &theme); + let before = (textarea_text(&textarea), textarea.cursor()); + + assert!(handle_idle_shortcut_invocation( + ShortcutInvocation::chord(ShortcutAction::Idle(IdleShortcut::ExpandToolOutput,)), + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &mut vim, + &theme, + )); + + assert_eq!((textarea_text(&textarea), textarea.cursor()), before); + } + + #[test] + fn chord_scroll_uses_transcript_even_for_multiline_composer() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.scroll_offset = 2; + state.auto_scroll = false; + state.total_lines = 20; + state.visible_height = 5; + let mut config = test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("first\nsecond", &vim, &theme); + let before = (textarea_text(&textarea), textarea.cursor()); + + assert!(handle_idle_shortcut_invocation( + ShortcutInvocation::chord(ShortcutAction::Idle(IdleShortcut::ScrollUp)), + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &mut vim, + &theme, + )); + + assert_eq!((textarea_text(&textarea), textarea.cursor()), before); + assert_eq!(state.scroll_offset, 1); + } + + #[test] + fn chord_history_previous_recalls_history_from_multiline_composer() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.input_history = vec!["prior prompt".to_string()]; + let mut config = test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("draft\ncontinued", &vim, &theme); + + assert!(handle_idle_shortcut_invocation( + ShortcutInvocation::chord(ShortcutAction::Idle(IdleShortcut::HistoryPrevious,)), + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &mut vim, + &theme, + )); + + assert_eq!(textarea_text(&textarea), "prior prompt"); + } + + #[test] + fn dynamic_idle_chord_starts_after_menus_and_submits_on_completion() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("send me", &vim, &theme); + let keymap = crate::keybindings::parse_keymap( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+x ctrl+s"]}}"#, + ) + .unwrap(); + let mut runtime = crate::keybindings::KeymapRuntime::new(keymap); + let owner = crate::app::input_owner_fingerprint(&state, &vim); + let now = std::time::Instant::now(); + + assert!(handle_idle_key_dynamic( + &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL,)), + &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + now, + owner, + &mut runtime, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &mut vim, + &theme, + )); + let continuation = runtime.advance_pending( + owner, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), + now + std::time::Duration::from_millis(1), + ); + let crate::keybindings::ShortcutResolution::Action(invocation) = continuation else { + panic!("expected completed contextual chord"); + }; + assert!(handle_idle_shortcut_invocation( + invocation, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &mut vim, + &theme, + )); + + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitWithMentions { ref prompt, .. }) if prompt == "send me" + )); } } diff --git a/crates/orca-tui/src/idle_navigation_actions.rs b/crates/orca-tui/src/idle_navigation_actions.rs index 681e4294..eb281fee 100644 --- a/crates/orca-tui/src/idle_navigation_actions.rs +++ b/crates/orca-tui/src/idle_navigation_actions.rs @@ -60,6 +60,7 @@ pub(crate) fn handle_idle_navigation_shortcut( } IdleShortcut::ExpandToolOutput => { if textarea_text(textarea).trim().is_empty() && state.toggle_latest_tool_output() { + vim_state.cancel_pending_command(); state.scroll_to_bottom(); } else { apply_composer_key_input(ev, key, state, config, textarea, vim_state, theme); @@ -67,6 +68,7 @@ pub(crate) fn handle_idle_navigation_shortcut( } IdleShortcut::Submit | IdleShortcut::Newline + | IdleShortcut::EditLatestQueued | IdleShortcut::HistoryPrevious | IdleShortcut::HistoryNext => {} } diff --git a/crates/orca-tui/src/idle_submit_actions.rs b/crates/orca-tui/src/idle_submit_actions.rs index 866a1a2d..18524abd 100644 --- a/crates/orca-tui/src/idle_submit_actions.rs +++ b/crates/orca-tui/src/idle_submit_actions.rs @@ -62,6 +62,7 @@ pub(crate) fn handle_idle_submit( let _ = action_tx.send(UserAction::RespondToInteraction { key, response }); } } else { + state.resume_queued_follow_up_autosend(); state.record_prompt(text.clone()); state.push_message(ChatMessage::User(visible_text.trim().to_string())); state.enter_running(); @@ -82,3 +83,44 @@ fn reset_composer_after_submit(textarea: &mut TextArea, vim_state: &mut VimState vim_state.reset_insert(textarea, theme); *textarea = make_textarea(vim_state, theme); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::composer_textarea::make_textarea_with_text; + use crate::test_support::test_run_config; + use orca_core::config::ThemeName; + + #[test] + fn idle_submit_resumes_queued_autosend() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.suspend_queued_follow_up_autosend(); + let mut config = test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("new foreground", &vim, &theme); + + assert!(handle_idle_submit( + &mut textarea, + &mut vim, + &theme, + &mut state, + &mut config, + &shared, + &action_tx, + )); + assert!(state.queued_follow_up_autosend); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitWithMentions { prompt, .. }) + if prompt == "new foreground" + )); + } +} diff --git a/crates/orca-tui/src/input_adapter.rs b/crates/orca-tui/src/input_adapter.rs new file mode 100644 index 00000000..9c62bc78 --- /dev/null +++ b/crates/orca-tui/src/input_adapter.rs @@ -0,0 +1,484 @@ +use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use qwertty::{ + Event as QwerttyEvent, FocusState, Key, KeyEvent as QwerttyKeyEvent, + KeyEventKind as QwerttyKeyEventKind, Modifiers, MouseButton as QwerttyMouseButton, + MouseEvent as QwerttyMouseEvent, MouseEventKind as QwerttyMouseEventKind, PasteEvent, + ScrollDirection, +}; + +#[derive(Default)] +pub(crate) struct InputAdapter { + paste: Option>, + legacy_alt_prefix: bool, +} + +impl InputAdapter { + pub(crate) fn adapt(&mut self, event: QwerttyEvent) -> Option { + match event { + QwerttyEvent::Key(key) => { + let legacy_alt = std::mem::take(&mut self.legacy_alt_prefix); + let mut key = adapt_key(key)?; + if legacy_alt { + key.modifiers.insert(KeyModifiers::ALT); + } + Some(Event::Key(key)) + } + QwerttyEvent::Mouse(mouse) => { + self.legacy_alt_prefix = false; + adapt_mouse(mouse).map(Event::Mouse) + } + QwerttyEvent::Focus(focus) => match focus.state() { + FocusState::Gained => { + self.legacy_alt_prefix = false; + Some(Event::FocusGained) + } + FocusState::Lost => { + self.legacy_alt_prefix = false; + Some(Event::FocusLost) + } + _ => None, + }, + QwerttyEvent::Resize(resize) => { + self.legacy_alt_prefix = false; + let cells = resize.cells(); + Some(Event::Resize(cells.columns(), cells.rows())) + } + QwerttyEvent::Paste(paste) => { + self.legacy_alt_prefix = false; + self.adapt_paste(paste) + } + QwerttyEvent::Syntax(syntax) => self.adapt_syntax(syntax), + _ => { + self.legacy_alt_prefix = false; + None + } + } + } + + fn adapt_syntax(&mut self, syntax: qwertty::SyntaxToken) -> Option { + let bytes = syntax.as_bytes(); + if bytes == [0x1b] { + self.legacy_alt_prefix = true; + return None; + } + self.legacy_alt_prefix = false; + let [0x1b, byte] = bytes else { + return None; + }; + let code = match *byte { + b'\r' => KeyCode::Enter, + b'\t' => KeyCode::Tab, + 0x08 | 0x7f => KeyCode::Backspace, + 0x20..=0x7e => KeyCode::Char(char::from(*byte)), + _ => return None, + }; + Some(Event::Key(KeyEvent::new(code, KeyModifiers::ALT))) + } + + fn adapt_paste(&mut self, segment: PasteEvent) -> Option { + if segment.is_first() { + self.paste = Some(Vec::new()); + } + let bytes = self.paste.as_mut()?; + bytes.extend_from_slice(segment.data()); + if !segment.is_final() { + return None; + } + let bytes = self.paste.take()?; + String::from_utf8(bytes).ok().map(Event::Paste) + } +} + +fn adapt_key(key: QwerttyKeyEvent) -> Option { + let mut modifiers = adapt_modifiers(key.modifiers()); + let mut code = match key.key() { + Key::Char(character) => KeyCode::Char(character), + Key::Up => KeyCode::Up, + Key::Down => KeyCode::Down, + Key::Right => KeyCode::Right, + Key::Left => KeyCode::Left, + Key::Enter => KeyCode::Enter, + Key::Tab if key.modifiers().contains(Modifiers::SHIFT) => KeyCode::BackTab, + Key::Tab => KeyCode::Tab, + Key::Backspace => KeyCode::Backspace, + Key::Escape => KeyCode::Esc, + Key::Home => KeyCode::Home, + Key::End => KeyCode::End, + Key::PageUp => KeyCode::PageUp, + Key::PageDown => KeyCode::PageDown, + Key::Insert => KeyCode::Insert, + Key::Delete => KeyCode::Delete, + Key::Function(number @ 1..=35) => KeyCode::F(number), + Key::Control(control) => { + modifiers.insert(KeyModifiers::CONTROL); + KeyCode::Char(control_character(control)?) + } + _ => return None, + }; + if modifiers.contains(KeyModifiers::SHIFT) + && let Some(shifted) = key.shifted_key() + { + code = KeyCode::Char(shifted); + modifiers.remove(KeyModifiers::SHIFT); + } + let kind = match key.kind() { + QwerttyKeyEventKind::Press => KeyEventKind::Press, + QwerttyKeyEventKind::Repeat => KeyEventKind::Repeat, + QwerttyKeyEventKind::Release => KeyEventKind::Release, + _ => return None, + }; + let mut state = KeyEventState::empty(); + if key.modifiers().contains(Modifiers::CAPS_LOCK) { + state.insert(KeyEventState::CAPS_LOCK); + } + if key.modifiers().contains(Modifiers::NUM_LOCK) { + state.insert(KeyEventState::NUM_LOCK); + } + Some(KeyEvent::new_with_kind_and_state( + code, modifiers, kind, state, + )) +} + +fn control_character(control: u8) -> Option { + match control { + 0 => Some(' '), + 1..=26 => Some(char::from(b'a' + control - 1)), + 28..=31 => Some(char::from(b'4' + control - 28)), + _ => None, + } +} + +fn adapt_modifiers(modifiers: Modifiers) -> KeyModifiers { + let mut adapted = KeyModifiers::empty(); + for (source, target) in [ + (Modifiers::SHIFT, KeyModifiers::SHIFT), + (Modifiers::CTRL, KeyModifiers::CONTROL), + (Modifiers::ALT, KeyModifiers::ALT), + (Modifiers::SUPER, KeyModifiers::SUPER), + (Modifiers::HYPER, KeyModifiers::HYPER), + (Modifiers::META, KeyModifiers::META), + ] { + if modifiers.contains(source) { + adapted.insert(target); + } + } + adapted +} + +fn adapt_mouse(mouse: QwerttyMouseEvent) -> Option { + let column = mouse.column().checked_sub(1)?; + let row = mouse.row().checked_sub(1)?; + let kind = match mouse.kind() { + QwerttyMouseEventKind::Press => MouseEventKind::Down(adapt_mouse_button(mouse.button())?), + QwerttyMouseEventKind::Release => MouseEventKind::Up(adapt_mouse_button(mouse.button())?), + QwerttyMouseEventKind::Moved => match mouse.button() { + QwerttyMouseButton::None => MouseEventKind::Moved, + button => MouseEventKind::Drag(adapt_mouse_button(button)?), + }, + QwerttyMouseEventKind::Scroll(direction) => match direction { + ScrollDirection::Up => MouseEventKind::ScrollUp, + ScrollDirection::Down => MouseEventKind::ScrollDown, + ScrollDirection::Left => MouseEventKind::ScrollLeft, + ScrollDirection::Right => MouseEventKind::ScrollRight, + _ => return None, + }, + _ => return None, + }; + Some(MouseEvent { + kind, + column, + row, + modifiers: adapt_modifiers(mouse.modifiers()), + }) +} + +fn adapt_mouse_button(button: QwerttyMouseButton) -> Option { + match button { + QwerttyMouseButton::Left => Some(MouseButton::Left), + QwerttyMouseButton::Middle => Some(MouseButton::Middle), + QwerttyMouseButton::Right => Some(MouseButton::Right), + QwerttyMouseButton::Other(_) | QwerttyMouseButton::None => None, + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, + MouseEventKind, + }; + use qwertty::{ + Event as QwerttyEvent, Key, KeyEvent as QwerttyKeyEvent, + KeyEventKind as QwerttyKeyEventKind, Modifiers, SemanticDecoder, + }; + + use super::InputAdapter; + + fn decode(bytes: &[u8]) -> Vec { + let mut decoder = SemanticDecoder::new(); + let mut events = decoder.feed(bytes); + events.extend(decoder.finish()); + events + } + + fn adapt_key(key: QwerttyKeyEvent) -> KeyEvent { + let mut adapter = InputAdapter::default(); + match adapter.adapt(QwerttyEvent::Key(key)) { + Some(Event::Key(key)) => key, + other => panic!("expected adapted key, got {other:?}"), + } + } + + #[test] + fn maps_named_character_function_and_control_keys() { + for (source, expected) in [ + (Key::Char('界'), KeyCode::Char('界')), + (Key::Up, KeyCode::Up), + (Key::Down, KeyCode::Down), + (Key::Left, KeyCode::Left), + (Key::Right, KeyCode::Right), + (Key::Enter, KeyCode::Enter), + (Key::Tab, KeyCode::Tab), + (Key::Backspace, KeyCode::Backspace), + (Key::Escape, KeyCode::Esc), + (Key::Home, KeyCode::Home), + (Key::End, KeyCode::End), + (Key::PageUp, KeyCode::PageUp), + (Key::PageDown, KeyCode::PageDown), + (Key::Insert, KeyCode::Insert), + (Key::Delete, KeyCode::Delete), + (Key::Function(1), KeyCode::F(1)), + (Key::Function(35), KeyCode::F(35)), + ] { + assert_eq!(adapt_key(QwerttyKeyEvent::new(source)).code, expected); + } + + for (control, expected) in [(0, ' '), (1, 'a'), (26, 'z'), (28, '4'), (31, '7')] { + let key = adapt_key(QwerttyKeyEvent::new(Key::Control(control))); + assert_eq!(key.code, KeyCode::Char(expected)); + assert_eq!(key.modifiers, KeyModifiers::CONTROL); + } + } + + #[test] + fn maps_shift_tab_modifiers_kind_and_lock_state() { + let modifiers = Modifiers::SHIFT + .union(Modifiers::CTRL) + .union(Modifiers::ALT) + .union(Modifiers::SUPER) + .union(Modifiers::HYPER) + .union(Modifiers::META) + .union(Modifiers::CAPS_LOCK) + .union(Modifiers::NUM_LOCK); + let key = adapt_key( + QwerttyKeyEvent::new(Key::Tab) + .with_modifiers(modifiers) + .with_kind(QwerttyKeyEventKind::Release), + ); + + assert_eq!(key.code, KeyCode::BackTab); + assert_eq!( + key.modifiers, + KeyModifiers::SHIFT + | KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META + ); + assert_eq!(key.kind, KeyEventKind::Release); + assert_eq!( + key.state, + KeyEventState::CAPS_LOCK | KeyEventState::NUM_LOCK + ); + + assert_eq!( + adapt_key(QwerttyKeyEvent::new(Key::Char('x')).with_kind(QwerttyKeyEventKind::Repeat)) + .kind, + KeyEventKind::Repeat + ); + } + + #[test] + fn maps_reported_shifted_character_like_crossterm() { + let key = adapt_key( + QwerttyKeyEvent::new(Key::Char('9')) + .with_shifted_key('(') + .with_modifiers(Modifiers::SHIFT.union(Modifiers::ALT)), + ); + + assert_eq!(key.code, KeyCode::Char('(')); + assert_eq!(key.modifiers, KeyModifiers::ALT); + } + + #[test] + fn preserves_legacy_alt_enter_and_alt_character_sequences() { + let mut adapter = InputAdapter::default(); + let alt_enter = decode(b"\x1b\r") + .into_iter() + .filter_map(|event| adapter.adapt(event)) + .collect::>(); + assert_eq!( + alt_enter, + [Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT))] + ); + + let alt_character = decode(b"\x1bx") + .into_iter() + .filter_map(|event| adapter.adapt(event)) + .collect::>(); + assert_eq!( + alt_character, + [Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::ALT + ))] + ); + } + + #[test] + fn unknown_syntax_does_not_prime_the_next_key_as_alt() { + let mut adapter = InputAdapter::default(); + let adapted = decode(b"\x1b]777;late\x07\r") + .into_iter() + .filter_map(|event| adapter.adapt(event)) + .collect::>(); + + assert_eq!( + adapted, + [Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE + ))] + ); + } + + #[test] + fn unsupported_key_consumes_legacy_alt_prefix() { + let mut adapter = InputAdapter::default(); + let mut events = decode(b"\x1b\r"); + let enter = events.pop().expect("enter event"); + let prefix = events.pop().expect("legacy alt prefix"); + + assert_eq!(adapter.adapt(prefix), None); + assert_eq!( + adapter.adapt(QwerttyEvent::Key(QwerttyKeyEvent::new(Key::Function(0)))), + None + ); + assert_eq!( + adapter.adapt(enter), + Some(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE + ))) + ); + } + + #[test] + fn maps_mouse_coordinates_buttons_drag_and_scroll() { + let fixtures = [ + ( + b"\x1b[<0;1;1M".as_slice(), + MouseEventKind::Down(MouseButton::Left), + 0, + 0, + ), + ( + b"\x1b[<1;4;5m".as_slice(), + MouseEventKind::Up(MouseButton::Middle), + 3, + 4, + ), + ( + b"\x1b[<34;8;9M".as_slice(), + MouseEventKind::Drag(MouseButton::Right), + 7, + 8, + ), + (b"\x1b[<35;2;3M".as_slice(), MouseEventKind::Moved, 1, 2), + (b"\x1b[<64;2;3M".as_slice(), MouseEventKind::ScrollUp, 1, 2), + ( + b"\x1b[<65;2;3M".as_slice(), + MouseEventKind::ScrollDown, + 1, + 2, + ), + ( + b"\x1b[<66;2;3M".as_slice(), + MouseEventKind::ScrollLeft, + 1, + 2, + ), + ( + b"\x1b[<67;2;3M".as_slice(), + MouseEventKind::ScrollRight, + 1, + 2, + ), + ]; + + for (bytes, expected_kind, expected_column, expected_row) in fixtures { + let source = decode(bytes).pop().expect("one qwertty mouse event"); + let mut adapter = InputAdapter::default(); + let Some(Event::Mouse(mouse)) = adapter.adapt(source) else { + panic!("expected adapted mouse event"); + }; + assert_eq!(mouse.kind, expected_kind); + assert_eq!(mouse.column, expected_column); + assert_eq!(mouse.row, expected_row); + } + } + + #[test] + fn rejects_zero_mouse_coordinates_and_extra_buttons() { + for bytes in [b"\x1b[<0;0;1M".as_slice(), b"\x1b[<128;1;1M".as_slice()] { + let source = decode(bytes).pop().expect("one qwertty mouse event"); + assert_eq!(InputAdapter::default().adapt(source), None); + } + } + + #[test] + fn maps_focus_and_resize_and_drops_syntax() { + let mut adapter = InputAdapter::default(); + let events = decode(b"\x1b[I\x1b[O\x1b[48;24;80;0;0t\x1b]777;late\x07"); + let adapted = events + .into_iter() + .filter_map(|event| adapter.adapt(event)) + .collect::>(); + + assert_eq!( + adapted, + [Event::FocusGained, Event::FocusLost, Event::Resize(80, 24)] + ); + } + + #[test] + fn reassembles_segmented_utf8_paste_and_rejects_invalid_utf8() { + let mut decoder = SemanticDecoder::with_payload_limit(3); + let mut adapter = InputAdapter::default(); + let valid = decoder.feed("\x1b[200~a界b\x1b[201~".as_bytes()); + let adapted = valid + .into_iter() + .filter_map(|event| adapter.adapt(event)) + .collect::>(); + assert_eq!(adapted, [Event::Paste("a界b".to_string())]); + + let invalid = decoder.feed(b"\x1b[200~\xff\x1b[201~"); + assert!( + invalid + .into_iter() + .all(|event| adapter.adapt(event).is_none()) + ); + + let recovery = decoder.feed(b"\x1b[200~ok\x1b[201~"); + let recovered = recovery + .into_iter() + .filter_map(|event| adapter.adapt(event)) + .collect::>(); + assert_eq!(recovered, [Event::Paste("ok".to_string())]); + } +} diff --git a/crates/orca-tui/src/input_event_actions.rs b/crates/orca-tui/src/input_event_actions.rs index aceb8a52..9bfd709e 100644 --- a/crates/orca-tui/src/input_event_actions.rs +++ b/crates/orca-tui/src/input_event_actions.rs @@ -7,7 +7,9 @@ use orca_core::config::RunConfig; use crate::composer_input_actions::refresh_input_menus; use crate::composer_textarea::{insert_composer_paste, insert_pasted_text}; +use crate::onboarding::OnboardingStep; use crate::selection::{SelectionGranularity, TranscriptSelection}; +use crate::terminal_presentation::TerminalPresentation; use crate::types::{AppState, AppStatus, PanelMode}; #[derive(Debug, PartialEq)] @@ -30,6 +32,284 @@ pub(crate) fn should_queue_input_event(event: &Event) -> bool { ) } +pub(crate) fn consume_focus_event(event: &Event, presentation: &mut TerminalPresentation) -> bool { + match event { + Event::FocusGained => { + presentation.set_focused(true); + true + } + Event::FocusLost => { + presentation.set_focused(false); + true + } + _ => false, + } +} + +#[cfg(test)] +mod focus_tests { + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + + use super::consume_focus_event; + use crate::terminal_presentation::{TerminalPresentation, TerminalPresentationProfile}; + + fn presentation() -> TerminalPresentation { + TerminalPresentation::new( + true, + TerminalPresentationProfile { + osc9_supported: true, + tmux_passthrough: false, + }, + ) + } + + #[test] + fn consume_focus_event_updates_only_presentation_focus() { + let mut presentation = presentation(); + presentation.set_focused(false); + + assert!(consume_focus_event(&Event::FocusGained, &mut presentation)); + assert!(presentation.is_focused()); + assert!(consume_focus_event(&Event::FocusLost, &mut presentation)); + assert!(!presentation.is_focused()); + assert!(!consume_focus_event( + &Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &mut presentation + )); + assert!(!presentation.is_focused()); + } +} + +#[cfg(test)] +mod search_paste_tests { + use crossterm::event::Event; + use tui_textarea::TextArea; + + use super::handle_paste_event; + use crate::composer_textarea::textarea_text; + use crate::test_support::test_run_config; + use crate::types::AppState; + + #[test] + fn search_paste_updates_query_without_touching_composer() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.open_transcript_search(); + let config = test_run_config(); + let mut textarea = TextArea::from(["composer"]); + + assert!(handle_paste_event( + &Event::Paste("one\r\ntwo".to_string()), + &mut state, + &config, + &mut textarea, + )); + + assert_eq!(state.transcript_search.query(), "one two"); + assert_eq!(textarea_text(&textarea), "composer"); + assert!(state.pending_pastes.is_empty()); + } +} + +#[cfg(test)] +mod setup_paste_tests { + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + use orca_core::config::ThemeName; + use std::sync::{Arc, Mutex}; + use tui_textarea::TextArea; + + use super::handle_paste_event; + use crate::composer_textarea::textarea_text; + use crate::onboarding::{OnboardingError, OnboardingStep}; + use crate::setup_actions::handle_setup_key; + use crate::test_support::test_run_config; + use crate::theme::Theme; + use crate::types::{AppState, AppStatus}; + use crate::vim::VimState; + + fn setup_api_key_error(state: &mut AppState, textarea: &mut TextArea) { + state.status = AppStatus::Setup; + state.onboarding.set_step_for_test(OnboardingStep::ApiKey); + let mut config = test_run_config(); + let shared_config = Arc::new(Mutex::new(config.clone())); + let (action_tx, _action_rx) = crossbeam_channel::unbounded(); + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + + handle_setup_key( + &Event::Key(key), + &key, + state, + &mut config, + &shared_config, + &action_tx, + textarea, + &VimState::new(false), + &Theme::named(ThemeName::Dark), + None, + ) + .expect("blank API key submission"); + + assert_eq!( + state.onboarding.review_error(), + Some(OnboardingError::MissingApiKey), + ); + } + + #[test] + fn setup_paste_is_owned_only_by_the_typed_api_key_step() { + for step in OnboardingStep::ALL { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "auto".to_string(), + "/tmp".to_string(), + ); + state.status = AppStatus::Setup; + state.onboarding.set_step_for_test(step); + let config = test_run_config(); + let mut textarea = TextArea::default(); + + assert!(handle_paste_event( + &Event::Paste("secret".to_string()), + &mut state, + &config, + &mut textarea, + )); + + assert_eq!( + textarea_text(&textarea), + if step == OnboardingStep::ApiKey { + "secret" + } else { + "" + }, + "{step:?}", + ); + assert_eq!(state.onboarding.api_key(), None, "{step:?}"); + } + } + + #[test] + fn api_key_paste_clears_missing_key_error_after_inserting_nonblank_text() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "auto".to_string(), + "/tmp".to_string(), + ); + let config = test_run_config(); + let mut textarea = TextArea::default(); + setup_api_key_error(&mut state, &mut textarea); + + assert!(handle_paste_event( + &Event::Paste("secret".to_string()), + &mut state, + &config, + &mut textarea, + )); + + assert_eq!(textarea_text(&textarea), "secret"); + assert_eq!(state.onboarding.review_error(), None); + } + + #[test] + fn whitespace_only_api_key_paste_keeps_missing_key_error() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "auto".to_string(), + "/tmp".to_string(), + ); + let config = test_run_config(); + let mut textarea = TextArea::default(); + setup_api_key_error(&mut state, &mut textarea); + + assert!(handle_paste_event( + &Event::Paste(" \n\t".to_string()), + &mut state, + &config, + &mut textarea, + )); + + assert_eq!(textarea_text(&textarea), " \n\t"); + assert_eq!( + state.onboarding.review_error(), + Some(OnboardingError::MissingApiKey), + ); + } +} + +#[cfg(test)] +mod running_paste_tests { + use crossterm::event::Event; + use orca_core::config::ThemeName; + use tui_textarea::TextArea; + + use super::handle_paste_event; + use crate::composer_textarea::textarea_text; + use crate::queued_input_actions::{enqueue_composer_follow_up, restore_latest_queued_message}; + use crate::test_support::test_run_config; + use crate::theme::Theme; + use crate::types::AppState; + use crate::vim::VimState; + + #[test] + fn running_large_paste_queues_placeholder_and_restores_payload() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + let config = test_run_config(); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = TextArea::default(); + let pasted = "secret payload\n".repeat(100); + + assert!(handle_paste_event( + &Event::Paste(pasted.clone()), + &mut state, + &config, + &mut textarea, + )); + let placeholder = textarea_text(&textarea); + assert!(placeholder.starts_with("[Pasted Content ")); + + assert!(enqueue_composer_follow_up( + &mut state, + &mut textarea, + &mut vim, + &theme, + )); + state.set_status(crate::types::AppStatus::Idle); + assert!(state.begin_next_queued_message().is_some()); + state.commit_queued_submission_admission(); + assert_eq!(state.input_history.last().unwrap(), pasted.trim()); + state.rollback_queued_submission(); + state.set_status(crate::types::AppStatus::Running); + assert!(restore_latest_queued_message( + &mut state, + &mut textarea, + &mut vim, + &theme, + )); + assert_eq!(textarea_text(&textarea), placeholder); + assert_eq!(state.pending_pastes.len(), 1); + assert_eq!(state.pending_pastes[0].1, pasted); + } +} + /// A terminal resize re-wraps every transcript line, so content positions /// captured under the old width no longer describe the same text. Drop the /// selection rather than let it highlight (and copy) unrelated rows. @@ -163,11 +443,19 @@ pub(crate) fn handle_paste_event( let Event::Paste(pasted) = ev else { return false; }; + if state.transcript_search.open { + state.transcript_search.insert_paste(pasted); + state.refresh_transcript_search(); + return true; + } match state.status { - AppStatus::Setup if state.setup_step == 1 => { + AppStatus::Setup if state.onboarding.step() == OnboardingStep::ApiKey => { insert_pasted_text(textarea, pasted); + if textarea.lines().iter().any(|line| !line.trim().is_empty()) { + state.onboarding.clear_api_key_error(); + } } - AppStatus::Idle | AppStatus::WaitingUserInput => { + AppStatus::Idle | AppStatus::Running | AppStatus::WaitingUserInput => { if insert_composer_paste(textarea, &mut state.pending_pastes, pasted) { state.reset_history_navigation(); refresh_input_menus(textarea, state, config); @@ -449,6 +737,7 @@ mod tests { handle_scroll_lines, should_queue_input_event, }; use crate::theme::Theme; + use crate::transcript_view::TranscriptRenderContext; use crate::types::{ AppState, AppStatus, ApprovalDialog, ApprovalOption, ChatMessage, UserAction, }; @@ -494,11 +783,8 @@ mod tests { state.transcript_render_cache.prepare( &state.messages, &state.message_revisions, - 20, - &theme, - 0, - false, - |_, _, _, _, _| vec![Line::from("hello world")], + TranscriptRenderContext::new(&theme, 20, 0, false), + |_, _, _, _, _, _| vec![Line::from("hello world")], ); state.transcript_area = Some(Rect::new(0, 0, 20, 5)); state.viewport_base_row = 0; @@ -1136,6 +1422,7 @@ mod tests { #[test] fn slash_menu_click_selects_then_accepts() { let mut state = state_with_transcript(); + state.frame_area = Some(Rect::new(0, 0, 60, 24)); state.input_area = Some(Rect::new(0, 20, 60, 3)); state.slash_menu = Some(crate::types::SlashMenu { items: vec![ diff --git a/crates/orca-tui/src/input_runtime.rs b/crates/orca-tui/src/input_runtime.rs new file mode 100644 index 00000000..5f6159bb --- /dev/null +++ b/crates/orca-tui/src/input_runtime.rs @@ -0,0 +1,1398 @@ +use std::error::Error as _; +use std::io; +use std::panic; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, Once}; +use std::thread; +use std::time::Duration; + +use crossbeam_channel as mpsc; +use orca_core::config::ThemeName; +use qwertty::{ + Event as QwerttyEvent, KittyKeyboardFlags, MouseMode, RestoreHandle, Rgb, TokioTerminalSession, +}; +use tokio::sync::watch; + +use crate::input_adapter::InputAdapter; +use crate::terminal_capabilities::{ + TerminalColorLevel, TerminalProfile, system_color_level, terminal_background_from_rgb, +}; + +const CAPABILITY_PROBE_TIMEOUT: Duration = Duration::from_millis(250); +const INPUT_CHANNEL_CAPACITY: usize = 256; +const FOCUS_CHANNEL_CAPACITY: usize = 8; +const STARTUP_CHANNEL_CAPACITY: usize = 1; + +static PANIC_HOOK_INIT: Once = Once::new(); +static ACTIVE_RESTORE_HANDLE: Mutex> = Mutex::new(None); +static TERMINAL_OWNER_ACTIVE: AtomicBool = AtomicBool::new(false); + +struct ActiveRestore { + handle: RestoreHandle, + owner_thread: thread::ThreadId, + input_thread: thread::ThreadId, +} + +fn should_restore_for_panic( + owner_thread: thread::ThreadId, + input_thread: thread::ThreadId, + panicking_thread: thread::ThreadId, +) -> bool { + panicking_thread == owner_thread || panicking_thread == input_thread +} + +struct TerminalOwnerLease; + +impl TerminalOwnerLease { + fn acquire() -> io::Result { + if TERMINAL_OWNER_ACTIVE + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "another TUI already owns the terminal", + )); + } + Ok(Self) + } +} + +impl Drop for TerminalOwnerLease { + fn drop(&mut self) { + TERMINAL_OWNER_ACTIVE.store(false, Ordering::Release); + } +} + +enum StartupMessage { + Ready(TerminalProfile), + Failed { + kind: io::ErrorKind, + message: String, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct InputRuntimeOptions { + pub(crate) theme: ThemeName, + pub(crate) focus_events: bool, +} + +impl From for InputRuntimeOptions { + fn from(theme: ThemeName) -> Self { + Self { + theme, + focus_events: false, + } + } +} + +pub(crate) enum InputControl { + Suspend { + acknowledge: tokio::sync::oneshot::Sender<()>, + }, + Resumed, +} + +struct InputEventSender { + events: mpsc::Sender, + focus_events: Option>, +} + +impl InputEventSender { + fn with_focus( + events: mpsc::Sender, + focus_events: mpsc::Sender, + ) -> Self { + Self { + events, + focus_events: Some(focus_events), + } + } + + fn sender_for( + &self, + event: &crossterm::event::Event, + ) -> &mpsc::Sender { + if matches!( + event, + crossterm::event::Event::FocusGained | crossterm::event::Event::FocusLost + ) { + self.focus_events.as_ref().unwrap_or(&self.events) + } else { + &self.events + } + } +} + +impl From> for InputEventSender { + fn from(events: mpsc::Sender) -> Self { + Self { + events, + focus_events: None, + } + } +} + +pub(crate) struct InputRuntime { + profile: TerminalProfile, + events: mpsc::Receiver, + focus_events: mpsc::Receiver, + controls: mpsc::Receiver, + stop_tx: Option>, + join: Option>>, + owner_lease: Option, +} + +impl InputRuntime { + pub(crate) fn start(options: InputRuntimeOptions) -> io::Result { + let owner_lease = TerminalOwnerLease::acquire()?; + let owner_thread = thread::current().id(); + + let color_level = system_color_level(); + let (event_tx, events) = mpsc::bounded(INPUT_CHANNEL_CAPACITY); + let (focus_tx, focus_events) = mpsc::bounded(FOCUS_CHANNEL_CAPACITY); + let (control_tx, controls) = mpsc::bounded(1); + let (startup_tx, startup_rx) = mpsc::bounded(STARTUP_CHANNEL_CAPACITY); + let (stop_tx, stop_rx) = watch::channel(false); + let join = match thread::Builder::new() + .name("orca-tui-input".to_string()) + .spawn(move || { + input_thread( + options, + color_level, + startup_tx, + InputEventSender::with_focus(event_tx, focus_tx), + control_tx, + stop_rx, + owner_thread, + ) + }) { + Ok(join) => join, + Err(error) => return Err(error), + }; + + match startup_rx.recv() { + Ok(StartupMessage::Ready(profile)) => Ok(Self { + profile, + events, + focus_events, + controls, + stop_tx: Some(stop_tx), + join: Some(join), + owner_lease: Some(owner_lease), + }), + Ok(StartupMessage::Failed { kind, message }) => { + let _ = join.join(); + Err(io::Error::new(kind, message)) + } + Err(_) => { + let thread_error = match join.join() { + Ok(result) => result.err(), + Err(_) => Some(io::Error::other("terminal input thread panicked")), + }; + Err(thread_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::UnexpectedEof, + "terminal input thread exited before startup completed", + ) + })) + } + } + } + + pub(crate) const fn profile(&self) -> TerminalProfile { + self.profile + } + + pub(crate) fn events(&self) -> &mpsc::Receiver { + &self.events + } + + pub(crate) fn focus_events(&self) -> &mpsc::Receiver { + &self.focus_events + } + + pub(crate) fn controls(&self) -> &mpsc::Receiver { + &self.controls + } + + pub(crate) fn finish(&mut self) -> io::Result<()> { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(true); + } + let result = if let Some(join) = self.join.take() { + match join.join() { + Ok(result) => result, + Err(_) => Err(io::Error::other("terminal input thread panicked")), + } + } else { + Ok(()) + }; + self.owner_lease.take(); + result + } + + #[cfg(test)] + fn from_parts_for_test( + profile: TerminalProfile, + events: mpsc::Receiver, + controls: mpsc::Receiver, + stop_tx: watch::Sender, + join: thread::JoinHandle>, + ) -> Self { + let (_focus_tx, focus_events) = mpsc::bounded(1); + Self { + profile, + events, + focus_events, + controls, + stop_tx: Some(stop_tx), + join: Some(join), + owner_lease: Some(TerminalOwnerLease), + } + } +} + +impl Drop for InputRuntime { + fn drop(&mut self) { + let _ = self.finish(); + } +} + +fn input_thread( + options: InputRuntimeOptions, + color_level: TerminalColorLevel, + startup_tx: mpsc::Sender, + event_tx: InputEventSender, + control_tx: mpsc::Sender, + stop_rx: watch::Receiver, + owner_thread: thread::ThreadId, +) -> io::Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async move { + let (driver, restore_handle) = match QwerttyDriver::open() { + Ok(driver) => driver, + Err(error) => { + let _ = startup_tx.send(StartupMessage::Failed { + kind: error.kind(), + message: error.to_string(), + }); + return Err(error); + } + }; + let _restore_registration = RestoreRegistration::install(restore_handle, owner_thread)?; + drive_terminal( + driver, + options, + color_level, + startup_tx, + event_tx, + control_tx, + stop_rx, + ) + .await + }) +} + +struct RestoreRegistration; + +impl RestoreRegistration { + fn install(handle: RestoreHandle, owner_thread: thread::ThreadId) -> io::Result { + PANIC_HOOK_INIT.call_once(|| { + let previous = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if let Ok(slot) = ACTIVE_RESTORE_HANDLE.try_lock() + && let Some(active) = slot.as_ref() + && should_restore_for_panic( + active.owner_thread, + active.input_thread, + thread::current().id(), + ) + { + let _ = active.handle.restore(); + } + previous(info); + })); + }); + let mut slot = ACTIVE_RESTORE_HANDLE + .lock() + .map_err(|_| io::Error::other("terminal restore registry is poisoned"))?; + if slot.is_some() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "terminal restore handle is already registered", + )); + } + *slot = Some(ActiveRestore { + handle, + owner_thread, + input_thread: thread::current().id(), + }); + Ok(Self) + } +} + +impl Drop for RestoreRegistration { + fn drop(&mut self) { + if let Ok(mut slot) = ACTIVE_RESTORE_HANDLE.lock() { + *slot = None; + } + } +} + +struct QwerttyDriver { + session: TokioTerminalSession, + signals: qwertty::SignalStream, + #[cfg(unix)] + resizes: qwertty::ResizeStream, +} + +impl QwerttyDriver { + fn open() -> io::Result<(Self, RestoreHandle)> { + let session = TokioTerminalSession::open().map_err(qwertty_error)?; + let restore_handle = session.restore_handle(); + let signals = session.signals().map_err(qwertty_error)?; + #[cfg(unix)] + let resizes = session.resize_stream().map_err(qwertty_error)?; + Ok(( + Self { + session, + signals, + #[cfg(unix)] + resizes, + }, + restore_handle, + )) + } +} + +#[derive(Clone, Debug)] +enum TerminalActivity { + Event(QwerttyEvent), + Suspend, + Continue, + Terminate, + Ignore, +} + +trait TerminalDriver: Sized { + async fn probe_background(&mut self, timeout: Duration) -> io::Result>; + async fn enter_alternate_screen(&mut self) -> io::Result<()>; + async fn enable_mouse(&mut self) -> io::Result<()>; + async fn enable_bracketed_paste(&mut self) -> io::Result<()>; + async fn enable_focus_events(&mut self) -> io::Result<()>; + async fn push_keyboard_flags(&mut self) -> io::Result<()>; + async fn next_activity(&mut self) -> io::Result; + async fn suspend(&mut self) -> io::Result<()>; + async fn resume(&mut self) -> io::Result<()>; + async fn leave(self) -> io::Result<()>; +} + +impl TerminalDriver for QwerttyDriver { + async fn probe_background(&mut self, timeout: Duration) -> io::Result> { + self.session + .probe_capabilities(timeout) + .await + .map(|capabilities| capabilities.background_color.value_copied()) + .map_err(qwertty_error) + } + + async fn enter_alternate_screen(&mut self) -> io::Result<()> { + self.session + .enter_alternate_screen() + .await + .map_err(qwertty_error) + } + + async fn enable_mouse(&mut self) -> io::Result<()> { + self.session + .enable_mouse(MouseMode::ButtonEvent) + .await + .map_err(qwertty_error) + } + + async fn enable_bracketed_paste(&mut self) -> io::Result<()> { + self.session + .enable_bracketed_paste() + .await + .map_err(qwertty_error) + } + + async fn enable_focus_events(&mut self) -> io::Result<()> { + self.session + .enable_focus_events() + .await + .map_err(qwertty_error) + } + + async fn push_keyboard_flags(&mut self) -> io::Result<()> { + let flags = KittyKeyboardFlags::DISAMBIGUATE_ESCAPE_CODES + .union(KittyKeyboardFlags::REPORT_EVENT_TYPES) + .union(KittyKeyboardFlags::REPORT_ALTERNATE_KEYS); + self.session + .push_kitty_keyboard(flags) + .await + .map_err(qwertty_error) + } + + async fn next_activity(&mut self) -> io::Result { + #[cfg(unix)] + { + tokio::select! { + biased; + signal = self.signals.next() => { + signal.map(terminal_signal_activity).map_err(qwertty_error) + } + resize = self.resizes.next_resize() => { + resize + .map(QwerttyEvent::Resize) + .map(TerminalActivity::Event) + .map_err(qwertty_error) + } + event = self.session.next_event() => { + event.map(TerminalActivity::Event).map_err(qwertty_error) + }, + } + } + #[cfg(windows)] + { + tokio::select! { + biased; + signal = self.signals.next() => { + signal.map(terminal_signal_activity).map_err(qwertty_error) + } + event = self.session.next_event() => { + event.map(TerminalActivity::Event).map_err(qwertty_error) + } + } + } + } + + async fn suspend(&mut self) -> io::Result<()> { + self.session.suspend().await.map_err(qwertty_error) + } + + async fn resume(&mut self) -> io::Result<()> { + #[cfg(unix)] + { + self.session.resume(false).await.map_err(qwertty_error) + } + #[cfg(windows)] + { + Ok(()) + } + } + + async fn leave(self) -> io::Result<()> { + self.session.leave().await.map_err(qwertty_error) + } +} + +fn terminal_signal_activity(signal: qwertty::TerminalSignal) -> TerminalActivity { + match signal { + qwertty::TerminalSignal::Suspend => TerminalActivity::Suspend, + qwertty::TerminalSignal::Continue => TerminalActivity::Continue, + qwertty::TerminalSignal::Terminate | qwertty::TerminalSignal::Interrupt => { + TerminalActivity::Terminate + } + _ => TerminalActivity::Ignore, + } +} + +fn qwertty_error(error: qwertty::Error) -> io::Error { + let kind = error + .source() + .and_then(|source| source.downcast_ref::()) + .map_or(io::ErrorKind::Other, io::Error::kind); + io::Error::new(kind, error) +} + +async fn drive_terminal( + mut driver: D, + options: impl Into, + color_level: TerminalColorLevel, + startup_tx: mpsc::Sender, + event_tx: impl Into, + control_tx: mpsc::Sender, + mut stop_rx: watch::Receiver, +) -> io::Result<()> { + let options = options.into(); + let background = if options.theme == ThemeName::Auto { + match driver.probe_background(CAPABILITY_PROBE_TIMEOUT).await { + Ok(background) => background, + Err(error) => { + return fail_startup(driver, startup_tx, error).await; + } + } + } else { + None + }; + + if let Err(error) = driver.enter_alternate_screen().await { + return fail_startup(driver, startup_tx, error).await; + } + if let Err(error) = driver.enable_mouse().await { + return fail_startup(driver, startup_tx, error).await; + } + if let Err(error) = driver.enable_bracketed_paste().await { + return fail_startup(driver, startup_tx, error).await; + } + if options.focus_events + && let Err(error) = driver.enable_focus_events().await + { + return fail_startup(driver, startup_tx, error).await; + } + if let Err(error) = driver.push_keyboard_flags().await { + return fail_startup(driver, startup_tx, error).await; + } + + let profile = TerminalProfile { + background: terminal_background_from_rgb(options.theme, background), + color_level, + }; + if startup_tx.send(StartupMessage::Ready(profile)).is_err() { + return finish_driver( + driver, + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "TUI startup receiver closed", + )), + ) + .await; + } + + let mut adapter = InputAdapter::default(); + let mut event_tx = Some(event_tx.into()); + let mut control_tx = Some(control_tx); + let mut wait_for_main_teardown = false; + let mut suspended = false; + let operation = loop { + tokio::select! { + biased; + changed = stop_rx.changed() => { + match changed { + Ok(()) if *stop_rx.borrow() => break Ok(()), + Ok(()) => {} + Err(_) => break Ok(()), + } + } + activity = driver.next_activity() => { + let activity = match activity { + Ok(activity) => activity, + Err(error) => { + wait_for_main_teardown = true; + break Err(error); + } + }; + match activity { + TerminalActivity::Event(event) => { + if let Some(event) = adapter.adapt(event) { + let sender = event_tx + .as_ref() + .expect("event sender is live") + .sender_for(&event); + if !send_event_until_stopped( + sender, + event, + &mut stop_rx, + ) + .await + { + break Ok(()); + } + } + } + TerminalActivity::Suspend => { + let (acknowledge, acknowledged) = tokio::sync::oneshot::channel(); + if !send_control_until_stopped( + control_tx.as_ref().expect("control sender is live"), + InputControl::Suspend { acknowledge }, + &mut stop_rx, + ) + .await + { + break Ok(()); + } + tokio::select! { + biased; + changed = stop_rx.changed() => { + match changed { + Ok(()) if *stop_rx.borrow() => break Ok(()), + Ok(()) => continue, + Err(_) => break Ok(()), + } + } + acknowledged = acknowledged => { + if acknowledged.is_err() { + wait_for_main_teardown = true; + break Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "TUI suspend acknowledgement closed", + )); + } + } + } + if let Err(error) = driver.suspend().await { + wait_for_main_teardown = true; + break Err(error); + } + suspended = true; + } + TerminalActivity::Continue if suspended => { + if let Err(error) = driver.resume().await { + wait_for_main_teardown = true; + break Err(error); + } + suspended = false; + if !send_control_until_stopped( + control_tx.as_ref().expect("control sender is live"), + InputControl::Resumed, + &mut stop_rx, + ) + .await + { + break Ok(()); + } + } + TerminalActivity::Continue => {} + TerminalActivity::Terminate => { + wait_for_main_teardown = true; + break Ok(()); + } + TerminalActivity::Ignore => {} + } + } + } + }; + + if wait_for_main_teardown { + event_tx.take(); + control_tx.take(); + wait_for_stop(&mut stop_rx).await; + } + finish_driver(driver, operation).await +} + +async fn wait_for_stop(stop_rx: &mut watch::Receiver) { + while !*stop_rx.borrow() { + if stop_rx.changed().await.is_err() { + break; + } + } +} + +async fn fail_startup( + driver: D, + startup_tx: mpsc::Sender, + error: io::Error, +) -> io::Result<()> { + let kind = error.kind(); + let message = error.to_string(); + let result = finish_driver(driver, Err(error)).await; + let _ = startup_tx.send(StartupMessage::Failed { kind, message }); + result +} + +async fn finish_driver(driver: D, operation: io::Result<()>) -> io::Result<()> { + let leave = driver.leave().await; + operation.and(leave) +} + +async fn send_event_until_stopped( + sender: &mpsc::Sender, + mut event: crossterm::event::Event, + stop_rx: &mut watch::Receiver, +) -> bool { + loop { + match sender.try_send(event) { + Ok(()) => return true, + Err(mpsc::TrySendError::Disconnected(_)) => return false, + Err(mpsc::TrySendError::Full(returned)) => event = returned, + } + + tokio::select! { + biased; + changed = stop_rx.changed() => { + match changed { + Ok(()) if *stop_rx.borrow() => return false, + Ok(()) => {} + Err(_) => return false, + } + } + () = tokio::time::sleep(Duration::from_millis(1)) => {} + } + } +} + +async fn send_control_until_stopped( + sender: &mpsc::Sender, + mut control: InputControl, + stop_rx: &mut watch::Receiver, +) -> bool { + loop { + match sender.try_send(control) { + Ok(()) => return true, + Err(mpsc::TrySendError::Disconnected(_)) => return false, + Err(mpsc::TrySendError::Full(returned)) => control = returned, + } + + tokio::select! { + biased; + changed = stop_rx.changed() => { + match changed { + Ok(()) if *stop_rx.borrow() => return false, + Ok(()) => {} + Err(_) => return false, + } + } + () = tokio::time::sleep(Duration::from_millis(1)) => {} + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::io; + use std::sync::atomic::Ordering; + use std::sync::{Arc, Mutex}; + use std::thread::ThreadId; + use std::time::Duration; + + use crossbeam_channel as mpsc; + use orca_core::config::ThemeName; + use qwertty::{Event, Key, KeyEvent, Rgb}; + use tokio::sync::watch; + + use super::{ + InputControl, InputRuntimeOptions, StartupMessage, TERMINAL_OWNER_ACTIVE, TerminalActivity, + TerminalDriver, drive_terminal, should_restore_for_panic, + }; + use crate::terminal_capabilities::{TerminalBackground, TerminalColorLevel}; + + static OWNER_TEST_LOCK: Mutex<()> = Mutex::new(()); + + struct FakeDriver { + calls: Arc>>, + background: Option, + activities: VecDeque, + fail_at: Option<&'static str>, + } + + impl FakeDriver { + fn new( + calls: Arc>>, + background: Option, + events: impl IntoIterator, + ) -> Self { + Self { + calls, + background, + activities: events.into_iter().map(TerminalActivity::Event).collect(), + fail_at: None, + } + } + + fn with_activities( + calls: Arc>>, + activities: impl IntoIterator, + ) -> Self { + Self { + calls, + background: None, + activities: activities.into_iter().collect(), + fail_at: None, + } + } + + fn failing(mut self, operation: &'static str) -> Self { + self.fail_at = Some(operation); + self + } + + fn record(&self, operation: &'static str) -> io::Result<()> { + self.calls.lock().expect("calls lock").push(operation); + if self.fail_at == Some(operation) { + Err(io::Error::other(format!("{operation} failed"))) + } else { + Ok(()) + } + } + } + + impl TerminalDriver for FakeDriver { + async fn probe_background(&mut self, _timeout: Duration) -> io::Result> { + self.record("probe")?; + Ok(self.background) + } + + async fn enter_alternate_screen(&mut self) -> io::Result<()> { + self.record("alternate") + } + + async fn enable_mouse(&mut self) -> io::Result<()> { + self.record("mouse") + } + + async fn enable_bracketed_paste(&mut self) -> io::Result<()> { + self.record("paste") + } + + async fn enable_focus_events(&mut self) -> io::Result<()> { + self.record("focus") + } + + async fn push_keyboard_flags(&mut self) -> io::Result<()> { + self.record("keyboard") + } + + async fn next_activity(&mut self) -> io::Result { + self.record("read")?; + if let Some(activity) = self.activities.pop_front() { + Ok(activity) + } else { + std::future::pending().await + } + } + + async fn suspend(&mut self) -> io::Result<()> { + self.record("suspend") + } + + async fn resume(&mut self) -> io::Result<()> { + self.record("resume") + } + + async fn leave(self) -> io::Result<()> { + self.record("leave") + } + } + + fn light_background() -> Option { + Some(Rgb::new(255, 255, 255)) + } + + async fn wait_for_startup(receiver: &mpsc::Receiver) -> StartupMessage { + for _ in 0..100 { + if let Ok(message) = receiver.try_recv() { + return message; + } + tokio::task::yield_now().await; + } + panic!("startup message was not sent"); + } + + async fn wait_for_control(receiver: &mpsc::Receiver) -> InputControl { + for _ in 0..100 { + if let Ok(control) = receiver.try_recv() { + return control; + } + tokio::task::yield_now().await; + } + panic!("control message was not sent"); + } + + #[test] + fn auto_probe_precedes_modes_ready_reads_and_leave() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + let calls = Arc::new(Mutex::new(Vec::new())); + let driver = FakeDriver::new(Arc::clone(&calls), light_background(), []); + let (startup_tx, startup_rx) = mpsc::bounded(1); + let (event_tx, _event_rx) = mpsc::bounded(4); + let (control_tx, _control_rx) = mpsc::bounded(1); + let (stop_tx, stop_rx) = watch::channel(false); + + let task = tokio::spawn(drive_terminal( + driver, + ThemeName::Auto, + TerminalColorLevel::Ansi256, + startup_tx, + event_tx, + control_tx, + stop_rx, + )); + let StartupMessage::Ready(profile) = wait_for_startup(&startup_rx).await else { + panic!("expected ready startup message"); + }; + assert_eq!(profile.background, TerminalBackground::Light); + assert_eq!(profile.color_level, TerminalColorLevel::Ansi256); + stop_tx.send(true).expect("stop receiver alive"); + task.await.expect("driver task").expect("clean stop"); + + assert_eq!( + *calls.lock().expect("calls lock"), + [ + "probe", + "alternate", + "mouse", + "paste", + "keyboard", + "read", + "leave" + ] + ); + }); + } + + #[test] + fn explicit_theme_skips_probe_but_keeps_mode_order() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + let calls = Arc::new(Mutex::new(Vec::new())); + let driver = FakeDriver::new(Arc::clone(&calls), light_background(), []); + let (startup_tx, startup_rx) = mpsc::bounded(1); + let (event_tx, _event_rx) = mpsc::bounded(4); + let (control_tx, _control_rx) = mpsc::bounded(1); + let (stop_tx, stop_rx) = watch::channel(false); + + let task = tokio::spawn(drive_terminal( + driver, + ThemeName::Light, + TerminalColorLevel::TrueColor, + startup_tx, + event_tx, + control_tx, + stop_rx, + )); + assert!(matches!( + wait_for_startup(&startup_rx).await, + StartupMessage::Ready(_) + )); + stop_tx.send(true).expect("stop receiver alive"); + task.await.expect("driver task").expect("clean stop"); + + assert_eq!( + *calls.lock().expect("calls lock"), + ["alternate", "mouse", "paste", "keyboard", "read", "leave"] + ); + }); + } + + #[test] + fn focus_events_are_enabled_only_when_requested_before_keyboard_flags() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + for (focus_events, expected) in [ + ( + true, + vec![ + "alternate", + "mouse", + "paste", + "focus", + "keyboard", + "read", + "leave", + ], + ), + ( + false, + vec!["alternate", "mouse", "paste", "keyboard", "read", "leave"], + ), + ] { + let calls = Arc::new(Mutex::new(Vec::new())); + let driver = FakeDriver::new(Arc::clone(&calls), None, []); + let (startup_tx, startup_rx) = mpsc::bounded(1); + let (event_tx, _event_rx) = mpsc::bounded(1); + let (control_tx, _control_rx) = mpsc::bounded(1); + let (stop_tx, stop_rx) = watch::channel(false); + + let task = tokio::spawn(drive_terminal( + driver, + InputRuntimeOptions { + theme: ThemeName::Dark, + focus_events, + }, + TerminalColorLevel::TrueColor, + startup_tx, + event_tx, + control_tx, + stop_rx, + )); + let _ = wait_for_startup(&startup_rx).await; + stop_tx.send(true).expect("stop receiver alive"); + task.await.expect("driver task").expect("clean stop"); + + assert_eq!(*calls.lock().expect("calls lock"), expected); + } + }); + } + + #[test] + fn full_mailbox_never_blocks_stop_or_leave() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + let calls = Arc::new(Mutex::new(Vec::new())); + let events = [ + Event::Key(KeyEvent::new(Key::Char('a'))), + Event::Key(KeyEvent::new(Key::Char('b'))), + ]; + let driver = FakeDriver::new(Arc::clone(&calls), None, events); + let (startup_tx, startup_rx) = mpsc::bounded(1); + let (event_tx, event_rx) = mpsc::bounded(1); + let (control_tx, _control_rx) = mpsc::bounded(1); + let (stop_tx, stop_rx) = watch::channel(false); + + let task = tokio::spawn(drive_terminal( + driver, + ThemeName::Dark, + TerminalColorLevel::TrueColor, + startup_tx, + event_tx, + control_tx, + stop_rx, + )); + let _ = wait_for_startup(&startup_rx).await; + for _ in 0..100 { + if event_rx.len() == 1 + && calls + .lock() + .expect("calls lock") + .iter() + .filter(|call| **call == "read") + .count() + >= 2 + { + break; + } + tokio::task::yield_now().await; + } + stop_tx.send(true).expect("stop receiver alive"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("stop must not deadlock") + .expect("driver task") + .expect("clean stop"); + + assert_eq!(calls.lock().expect("calls lock").last(), Some(&"leave")); + }); + } + + #[test] + fn full_control_mailbox_never_blocks_stop_or_leave() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + let calls = Arc::new(Mutex::new(Vec::new())); + let driver = + FakeDriver::with_activities(Arc::clone(&calls), [TerminalActivity::Suspend]); + let (startup_tx, startup_rx) = mpsc::bounded(1); + let (event_tx, _event_rx) = mpsc::bounded(1); + let (control_tx, control_rx) = mpsc::bounded(1); + control_tx + .send(InputControl::Resumed) + .expect("control receiver alive"); + let (stop_tx, stop_rx) = watch::channel(false); + + let task = tokio::spawn(drive_terminal( + driver, + ThemeName::Dark, + TerminalColorLevel::TrueColor, + startup_tx, + event_tx, + control_tx, + stop_rx, + )); + let _ = wait_for_startup(&startup_rx).await; + for _ in 0..100 { + if calls.lock().expect("calls lock").contains(&"read") { + break; + } + tokio::task::yield_now().await; + } + stop_tx.send(true).expect("stop receiver alive"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("stop must interrupt a full control mailbox") + .expect("driver task") + .expect("clean stop"); + + drop(control_rx); + assert_eq!(calls.lock().expect("calls lock").last(), Some(&"leave")); + }); + } + + #[test] + fn startup_failure_is_reported_and_still_leaves() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + let calls = Arc::new(Mutex::new(Vec::new())); + let driver = FakeDriver::new(Arc::clone(&calls), None, []).failing("mouse"); + let (startup_tx, startup_rx) = mpsc::bounded(1); + let (event_tx, _event_rx) = mpsc::bounded(1); + let (control_tx, _control_rx) = mpsc::bounded(1); + let (_stop_tx, stop_rx) = watch::channel(false); + + let error = drive_terminal( + driver, + ThemeName::Dark, + TerminalColorLevel::TrueColor, + startup_tx, + event_tx, + control_tx, + stop_rx, + ) + .await + .expect_err("mode failure should fail startup"); + assert_eq!(error.to_string(), "mouse failed"); + assert!(matches!( + startup_rx.recv().expect("startup error"), + StartupMessage::Failed { .. } + )); + assert_eq!( + *calls.lock().expect("calls lock"), + ["alternate", "mouse", "leave"] + ); + }); + } + + #[test] + fn terminal_signals_suspend_resume_and_terminate_through_qwertty() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + let calls = Arc::new(Mutex::new(Vec::new())); + let driver = FakeDriver::with_activities( + Arc::clone(&calls), + [ + TerminalActivity::Suspend, + TerminalActivity::Continue, + TerminalActivity::Terminate, + ], + ); + let (startup_tx, startup_rx) = mpsc::bounded(1); + let (event_tx, event_rx) = mpsc::bounded(1); + let (control_tx, control_rx) = mpsc::bounded(1); + let (stop_tx, stop_rx) = watch::channel(false); + + let task = tokio::spawn(drive_terminal( + driver, + ThemeName::Dark, + TerminalColorLevel::TrueColor, + startup_tx, + event_tx, + control_tx, + stop_rx, + )); + assert!(matches!( + wait_for_startup(&startup_rx).await, + StartupMessage::Ready(_) + )); + let InputControl::Suspend { acknowledge } = wait_for_control(&control_rx).await + else { + panic!("expected suspend control"); + }; + acknowledge.send(()).expect("acknowledge suspend"); + assert!(matches!( + wait_for_control(&control_rx).await, + InputControl::Resumed + )); + for _ in 0..100 { + if matches!(event_rx.try_recv(), Err(mpsc::TryRecvError::Disconnected)) { + break; + } + tokio::task::yield_now().await; + } + assert!(matches!( + event_rx.try_recv(), + Err(mpsc::TryRecvError::Disconnected) + )); + stop_tx.send(true).expect("stop receiver alive"); + task.await.expect("driver task").expect("clean signal exit"); + + assert_eq!( + *calls.lock().expect("calls lock"), + [ + "alternate", + "mouse", + "paste", + "keyboard", + "read", + "suspend", + "read", + "resume", + "read", + "leave" + ] + ); + }); + } + + #[test] + fn closed_startup_receiver_still_leaves() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(async { + let calls = Arc::new(Mutex::new(Vec::new())); + let driver = FakeDriver::new(Arc::clone(&calls), None, []); + let (startup_tx, startup_rx) = mpsc::bounded(1); + drop(startup_rx); + let (event_tx, _event_rx) = mpsc::bounded(1); + let (control_tx, _control_rx) = mpsc::bounded(1); + let (_stop_tx, stop_rx) = watch::channel(false); + + let error = drive_terminal( + driver, + ThemeName::Dark, + TerminalColorLevel::TrueColor, + startup_tx, + event_tx, + control_tx, + stop_rx, + ) + .await + .expect_err("closed startup receiver should stop the driver"); + assert_eq!(error.kind(), io::ErrorKind::BrokenPipe); + assert_eq!( + *calls.lock().expect("calls lock"), + ["alternate", "mouse", "paste", "keyboard", "leave"] + ); + }); + } + + #[test] + fn input_runtime_finish_and_drop_signal_and_join_once() { + let _owner_guard = OWNER_TEST_LOCK.lock().expect("owner test lock"); + for explicit_finish in [true, false] { + let (stop_tx, mut stop_rx) = watch::channel(false); + let joined = Arc::new(Mutex::new(0)); + let thread_joined = Arc::clone(&joined); + let join = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + runtime.block_on(async { + stop_rx.changed().await.expect("stop sender alive"); + assert!(*stop_rx.borrow()); + }); + *thread_joined.lock().expect("join count lock") += 1; + Ok(()) + }); + let (_event_tx, event_rx) = mpsc::bounded(1); + let (_control_tx, control_rx) = mpsc::bounded(1); + let mut runtime = super::InputRuntime::from_parts_for_test( + super::TerminalProfile { + background: TerminalBackground::Unknown, + color_level: TerminalColorLevel::TrueColor, + }, + event_rx, + control_rx, + stop_tx, + join, + ); + + if explicit_finish { + runtime.finish().expect("finish should join"); + runtime.finish().expect("second finish is idempotent"); + } else { + drop(runtime); + } + assert_eq!(*joined.lock().expect("join count lock"), 1); + } + } + + #[test] + fn input_runtime_thread_panic_releases_global_ownership() { + let _owner_guard = OWNER_TEST_LOCK.lock().expect("owner test lock"); + TERMINAL_OWNER_ACTIVE.store(true, Ordering::Release); + let (stop_tx, _stop_rx) = watch::channel(false); + let (_event_tx, event_rx) = mpsc::bounded(1); + let (_control_tx, control_rx) = mpsc::bounded(1); + let join = std::thread::spawn(|| -> io::Result<()> { + panic!("simulated input thread panic"); + }); + let mut runtime = super::InputRuntime::from_parts_for_test( + super::TerminalProfile { + background: TerminalBackground::Unknown, + color_level: TerminalColorLevel::TrueColor, + }, + event_rx, + control_rx, + stop_tx, + join, + ); + + let error = runtime.finish().expect_err("thread panic must be reported"); + assert_eq!(error.to_string(), "terminal input thread panicked"); + assert!(!TERMINAL_OWNER_ACTIVE.load(Ordering::Acquire)); + } + + #[test] + fn finished_runtime_drop_does_not_release_a_replacement_owner() { + let _owner_guard = OWNER_TEST_LOCK.lock().expect("owner test lock"); + TERMINAL_OWNER_ACTIVE.store(true, Ordering::Release); + let (stop_tx, _stop_rx) = watch::channel(false); + let (_event_tx, event_rx) = mpsc::bounded(1); + let (_control_tx, control_rx) = mpsc::bounded(1); + let join = std::thread::spawn(|| Ok(())); + let mut old_runtime = super::InputRuntime::from_parts_for_test( + super::TerminalProfile { + background: TerminalBackground::Unknown, + color_level: TerminalColorLevel::TrueColor, + }, + event_rx, + control_rx, + stop_tx, + join, + ); + + old_runtime.finish().expect("old runtime should finish"); + assert!(!TERMINAL_OWNER_ACTIVE.load(Ordering::Acquire)); + assert!( + TERMINAL_OWNER_ACTIVE + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "replacement owner should acquire the terminal" + ); + + drop(old_runtime); + assert!( + TERMINAL_OWNER_ACTIVE.load(Ordering::Acquire), + "dropping the finished old runtime must not clear the replacement owner" + ); + TERMINAL_OWNER_ACTIVE.store(false, Ordering::Release); + } + + #[test] + fn panic_restore_is_scoped_to_terminal_owner_threads() { + let owner = std::thread::current().id(); + let input = std::thread::spawn(|| std::thread::current().id()) + .join() + .expect("input thread id"); + let worker = std::thread::spawn(|| std::thread::current().id()) + .join() + .expect("worker thread id"); + + assert!(should_restore_for_panic(owner, input, owner)); + assert!(should_restore_for_panic(owner, input, input)); + assert!(!should_restore_for_panic(owner, input, worker)); + + let _: [ThreadId; 3] = [owner, input, worker]; + } +} diff --git a/crates/orca-tui/src/key_event_actions.rs b/crates/orca-tui/src/key_event_actions.rs index 61240f14..7640e923 100644 --- a/crates/orca-tui/src/key_event_actions.rs +++ b/crates/orca-tui/src/key_event_actions.rs @@ -3,22 +3,361 @@ use std::sync::{Arc, Mutex}; use crossbeam_channel as mpsc; -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use orca_core::config::RunConfig; use crate::approval_mode_actions::cycle_approval_mode; use crate::global_actions::{GlobalShortcutFlow, handle_global_shortcut}; +use crate::keybindings::{ + InputOwnerFingerprint, KeymapRuntime, ShortcutInvocation, ShortcutResolution, +}; use crate::operation_controller::TuiOperationInterrupt; -use crate::shortcuts::{ShortcutAction, ShortcutContext, resolve_shortcut}; +use crate::shortcuts::ShortcutAction; +#[cfg(test)] +use crate::shortcuts::{GlobalShortcut, ShortcutContext, resolve_shortcut}; use crate::types::{AppState, AppStatus, PanelMode, UserAction}; +use crate::vim::VimState; +#[cfg(test)] +#[allow(dead_code)] pub(crate) enum KeyEventFlow { Continue, Exit(i32), Unhandled, } +pub(crate) enum DynamicKeyEventFlow { + Continue, + Exit(i32), + Context(ShortcutInvocation), + Unhandled, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SearchKeyFlow { + NotSearch, + Handled, +} + +pub(crate) fn handle_transcript_search_key(key: KeyEvent, state: &mut AppState) -> SearchKeyFlow { + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + || !state.transcript_search.open + { + return SearchKeyFlow::NotSearch; + } + + match key.code { + KeyCode::Esc => state.close_transcript_search(), + KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => { + state.search_previous(); + } + KeyCode::Enter => state.search_next(), + KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + state.search_previous(); + } else { + state.search_next(); + } + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + state.transcript_search.clear_query(); + state.refresh_transcript_search(); + } + KeyCode::Backspace => { + if state.transcript_search.backspace() { + state.refresh_transcript_search(); + } + } + KeyCode::Left => state.transcript_search.move_left(), + KeyCode::Right => state.transcript_search.move_right(), + KeyCode::Home => state.transcript_search.move_home(), + KeyCode::End => state.transcript_search.move_end(), + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => + { + state.transcript_search.insert_char(character); + state.refresh_transcript_search(); + } + _ => {} + } + SearchKeyFlow::Handled +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyModifiers; + + use crate::test_support::{TestOperationInterrupt, test_run_config}; + use crate::theme::Theme; + use crate::transcript_view::TranscriptRenderContext; + use crate::types::ChatMessage; + use crate::ui::build_lines_for_messages; + + fn state_with_search_matches() -> AppState { + let (tx, _rx) = mpsc::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.push_message(ChatMessage::System("alpha one".to_string())); + state.push_message(ChatMessage::System("alpha two".to_string())); + let theme = Theme::named(orca_core::config::ThemeName::Dark); + let messages = &state.messages; + let revisions = &state.message_revisions; + state.transcript_render_cache.prepare( + messages, + revisions, + TranscriptRenderContext::new(&theme, 40, 0, false), + |_, message, theme, width, tick, force_expand| { + build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); + state.open_transcript_search(); + state.replace_transcript_search_query("alpha"); + state.refresh_transcript_search(); + state + } + + #[test] + fn active_search_keys_edit_close_and_navigate_without_fallthrough() { + let mut state = state_with_search_matches(); + assert_eq!( + handle_transcript_search_key( + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE), + &mut state, + ), + SearchKeyFlow::Handled + ); + assert_eq!(state.transcript_search.query(), "alphaz"); + handle_transcript_search_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); + handle_transcript_search_key( + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + &mut state, + ); + assert_eq!(state.transcript_search.query(), "alphz"); + handle_transcript_search_key( + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + &mut state, + ); + assert_eq!(state.transcript_search.query(), ""); + + state.replace_transcript_search_query("alpha"); + state.refresh_transcript_search(); + let first = state.transcript_search.active_ordinal(); + handle_transcript_search_key( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + ); + assert_ne!(state.transcript_search.active_ordinal(), first); + handle_transcript_search_key( + KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), + &mut state, + ); + assert_eq!(state.transcript_search.active_ordinal(), first); + handle_transcript_search_key( + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), + &mut state, + ); + assert_ne!(state.transcript_search.active_ordinal(), first); + handle_transcript_search_key( + KeyEvent::new( + KeyCode::Char('g'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + &mut state, + ); + assert_eq!(state.transcript_search.active_ordinal(), first); + + handle_transcript_search_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &mut state); + assert!(!state.transcript_search.open); + } + + #[test] + fn search_ctrl_g_precedes_running_interrupt_and_ctrl_c_stays_global() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = state_with_search_matches(); + state.enter_running(); + let operation = TestOperationInterrupt::default(); + let mut config = test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + let mut vim = crate::vim::VimState::new(false); + + let ctrl_g = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL); + assert!(matches!( + handle_key_event_preflight( + ctrl_g, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(), + KeyEventFlow::Continue + )); + assert_eq!(operation.call_count(), 0); + assert!(action_rx.try_recv().is_err()); + + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + handle_key_event_preflight( + ctrl_c, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(); + assert_eq!(operation.call_count(), 1); + assert!(matches!(action_rx.try_recv(), Ok(UserAction::Interrupt))); + } + + #[test] + fn global_and_search_preflight_clear_only_pending_vim_command_state() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = state_with_search_matches(); + let mut config = test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let mut vim = crate::vim::VimState::new(true); + vim.seed_pending_count_for_test(); + vim.set_named_register_for_test(0, "saved"); + vim.set_repeat_for_test(); + + handle_key_event_preflight( + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(); + + assert!(!vim.has_pending_command_for_test()); + assert_eq!(vim.named_register_for_test(0), Some(("saved", false))); + assert!(vim.has_repeat_for_test()); + } + + #[test] + fn release_and_unknown_search_keys_do_not_mutate_query() { + let mut state = state_with_search_matches(); + let before = state.transcript_search.query().to_string(); + let release = KeyEvent { + kind: KeyEventKind::Release, + ..KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE) + }; + assert_eq!( + handle_transcript_search_key(release, &mut state), + SearchKeyFlow::NotSearch + ); + assert_eq!( + handle_transcript_search_key( + KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE), + &mut state, + ), + SearchKeyFlow::Handled + ); + assert_eq!(state.transcript_search.query(), before); + } + + #[test] + fn dynamic_global_chord_replaces_default_and_opens_search() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let mut vim = VimState::new(false); + let keymap = crate::keybindings::parse_keymap( + br#"{"version":1,"bindings":{"global.open-transcript-search":["ctrl+x ctrl+f"]}}"#, + ) + .unwrap(); + let mut runtime = crate::keybindings::KeymapRuntime::new(keymap); + let owner = crate::app::input_owner_fingerprint(&state, &vim); + let now = std::time::Instant::now(); + + assert!(matches!( + handle_key_event_preflight_dynamic( + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + now, + owner, + &mut runtime, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(), + DynamicKeyEventFlow::Unhandled, + )); + assert!(!state.transcript_search.open); + + assert!(matches!( + handle_key_event_preflight_dynamic( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + now, + owner, + &mut runtime, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(), + DynamicKeyEventFlow::Continue, + )); + assert!(matches!( + handle_key_event_preflight_dynamic( + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + now + std::time::Duration::from_millis(1), + owner, + &mut runtime, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut vim, + || Ok(()), + ) + .unwrap(), + DynamicKeyEventFlow::Continue, + )); + assert!(state.transcript_search.open); + } +} + +#[cfg(test)] pub(crate) fn handle_key_event_preflight( key: KeyEvent, state: &mut AppState, @@ -26,6 +365,7 @@ pub(crate) fn handle_key_event_preflight( shared_config: &Arc>, action_tx: &mpsc::Sender, operation: &impl TuiOperationInterrupt, + vim_state: &mut VimState, clear_terminal: F, ) -> io::Result where @@ -35,7 +375,29 @@ where return Ok(KeyEventFlow::Continue); } + if let Some(ShortcutAction::Global(GlobalShortcut::Cancel)) = + resolve_shortcut(ShortcutContext::Global, key) + { + vim_state.cancel_pending_command(); + return match handle_global_shortcut( + GlobalShortcut::Cancel, + state, + action_tx, + operation, + clear_terminal, + )? { + GlobalShortcutFlow::Continue => Ok(KeyEventFlow::Continue), + GlobalShortcutFlow::Exit(code) => Ok(KeyEventFlow::Exit(code)), + }; + } + + if handle_transcript_search_key(key, state) == SearchKeyFlow::Handled { + vim_state.cancel_pending_command(); + return Ok(KeyEventFlow::Continue); + } + if let Some(ShortcutAction::Global(shortcut)) = resolve_shortcut(ShortcutContext::Global, key) { + vim_state.cancel_pending_command(); return match handle_global_shortcut(shortcut, state, action_tx, operation, clear_terminal)? { GlobalShortcutFlow::Continue => Ok(KeyEventFlow::Continue), @@ -44,6 +406,7 @@ where } if state.show_shortcuts && key.code == KeyCode::Esc { + vim_state.cancel_pending_command(); state.show_shortcuts = false; return Ok(KeyEventFlow::Continue); } @@ -51,6 +414,7 @@ where // Esc dismisses an active mouse selection before any other Esc meaning // (cancel turn, close panel); a second Esc then does the usual thing. if key.code == KeyCode::Esc && state.selection.is_some() { + vim_state.cancel_pending_command(); state.invalidate_selection(); return Ok(KeyEventFlow::Continue); } @@ -61,6 +425,7 @@ where AppStatus::Idle | AppStatus::Running | AppStatus::WaitingUserInput ) { + vim_state.cancel_pending_command(); cycle_approval_mode(config, shared_config, state); return Ok(KeyEventFlow::Continue); } @@ -69,9 +434,133 @@ where && state.panel_mode == PanelMode::Workflows && key.code == KeyCode::Esc { + vim_state.cancel_pending_command(); state.show_conversation(); return Ok(KeyEventFlow::Continue); } Ok(KeyEventFlow::Unhandled) } + +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_key_event_preflight_dynamic( + key: KeyEvent, + now: std::time::Instant, + owner: InputOwnerFingerprint, + keymap: &mut KeymapRuntime, + state: &mut AppState, + config: &mut RunConfig, + shared_config: &Arc>, + action_tx: &mpsc::Sender, + operation: &impl TuiOperationInterrupt, + vim_state: &mut VimState, + clear_terminal: F, +) -> io::Result +where + F: FnOnce() -> io::Result<()>, +{ + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return Ok(DynamicKeyEventFlow::Continue); + } + + if let ShortcutResolution::Action(invocation) = keymap.resolve_cancel(key) { + return execute_global_invocation( + invocation, + state, + action_tx, + operation, + vim_state, + clear_terminal, + ); + } + + match keymap.advance_pending(owner, key, now) { + ShortcutResolution::Action(invocation) => { + return if matches!(invocation.action, ShortcutAction::Global(_)) { + execute_global_invocation( + invocation, + state, + action_tx, + operation, + vim_state, + clear_terminal, + ) + } else { + Ok(DynamicKeyEventFlow::Context(invocation)) + }; + } + ShortcutResolution::Pending => return Ok(DynamicKeyEventFlow::Continue), + ShortcutResolution::RetryCurrentKey | ShortcutResolution::NoMatch => {} + } + + if handle_transcript_search_key(key, state) == SearchKeyFlow::Handled { + vim_state.cancel_pending_command(); + return Ok(DynamicKeyEventFlow::Continue); + } + + match keymap.resolve_new_global(owner, key, now) { + ShortcutResolution::Action(invocation) => { + return execute_global_invocation( + invocation, + state, + action_tx, + operation, + vim_state, + clear_terminal, + ); + } + ShortcutResolution::Pending => return Ok(DynamicKeyEventFlow::Continue), + ShortcutResolution::RetryCurrentKey | ShortcutResolution::NoMatch => {} + } + + if state.show_shortcuts && key.code == KeyCode::Esc { + vim_state.cancel_pending_command(); + state.show_shortcuts = false; + return Ok(DynamicKeyEventFlow::Continue); + } + if key.code == KeyCode::Esc && state.selection.is_some() { + vim_state.cancel_pending_command(); + state.invalidate_selection(); + return Ok(DynamicKeyEventFlow::Continue); + } + if key.code == KeyCode::BackTab + && matches!( + state.status, + AppStatus::Idle | AppStatus::Running | AppStatus::WaitingUserInput + ) + { + vim_state.cancel_pending_command(); + cycle_approval_mode(config, shared_config, state); + return Ok(DynamicKeyEventFlow::Continue); + } + if state.status == AppStatus::Idle + && state.panel_mode == PanelMode::Workflows + && key.code == KeyCode::Esc + { + vim_state.cancel_pending_command(); + state.show_conversation(); + return Ok(DynamicKeyEventFlow::Continue); + } + Ok(DynamicKeyEventFlow::Unhandled) +} + +fn execute_global_invocation( + invocation: ShortcutInvocation, + state: &mut AppState, + action_tx: &mpsc::Sender, + operation: &impl TuiOperationInterrupt, + vim_state: &mut VimState, + clear_terminal: F, +) -> io::Result +where + F: FnOnce() -> io::Result<()>, +{ + let ShortcutAction::Global(shortcut) = invocation.action else { + return Ok(DynamicKeyEventFlow::Unhandled); + }; + vim_state.cancel_pending_command(); + match handle_global_shortcut(shortcut, state, action_tx, operation, clear_terminal)? { + GlobalShortcutFlow::Continue => Ok(DynamicKeyEventFlow::Continue), + GlobalShortcutFlow::Exit(code) => Ok(DynamicKeyEventFlow::Exit(code)), + } +} diff --git a/crates/orca-tui/src/keybindings/config.rs b/crates/orca-tui/src/keybindings/config.rs new file mode 100644 index 00000000..b3fbfc86 --- /dev/null +++ b/crates/orca-tui/src/keybindings/config.rs @@ -0,0 +1,898 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::sync::Arc; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use serde::de::{MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; + +use crate::shortcuts::{ + GlobalShortcut, ShortcutAction, ShortcutContext, ShortcutDescriptor, action_for_id, + configurable_legacy_bindings, normalize_key_parts, +}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) struct KeyStroke { + code: KeyCode, + modifiers: KeyModifiers, +} + +impl KeyStroke { + pub(crate) fn parse(source: &str) -> Result { + if source.is_empty() || source.chars().any(char::is_whitespace) { + return Err(format!("invalid key stroke `{source}`")); + } + let parts = source.split('+').collect::>(); + let (key_name, modifier_names) = parts + .split_last() + .ok_or_else(|| format!("invalid key stroke `{source}`"))?; + if key_name.is_empty() { + return Err(format!("invalid key stroke `{source}`")); + } + + let mut modifiers = KeyModifiers::NONE; + for modifier in modifier_names { + let flag = match modifier.to_ascii_lowercase().as_str() { + "ctrl" => KeyModifiers::CONTROL, + "alt" => KeyModifiers::ALT, + "shift" => KeyModifiers::SHIFT, + "super" => KeyModifiers::SUPER, + "hyper" => KeyModifiers::HYPER, + "meta" => KeyModifiers::META, + _ => return Err(format!("unknown modifier `{modifier}`")), + }; + if modifiers.contains(flag) { + return Err(format!("duplicate modifier `{modifier}`")); + } + modifiers.insert(flag); + } + + let code = parse_key_code(key_name)?; + Ok(Self { code, modifiers }) + } + + pub(crate) fn matches(self, event: KeyEvent) -> bool { + if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return false; + } + normalize_key_parts(self.code, self.modifiers) + == normalize_key_parts(event.code, event.modifiers) + } +} + +impl fmt::Display for KeyStroke { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for (modifier, label) in [ + (KeyModifiers::CONTROL, "ctrl+"), + (KeyModifiers::ALT, "alt+"), + (KeyModifiers::SHIFT, "shift+"), + (KeyModifiers::SUPER, "super+"), + (KeyModifiers::HYPER, "hyper+"), + (KeyModifiers::META, "meta+"), + ] { + if self.modifiers.contains(modifier) { + formatter.write_str(label)?; + } + } + formatter.write_str(&format_key_code(self.code)) + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct KeySequence(Vec); + +impl KeySequence { + fn parse(source: &str) -> Result { + let strokes = source + .split_whitespace() + .map(KeyStroke::parse) + .collect::, _>>() + .map_err(KeymapError)?; + if strokes.is_empty() { + return Err(KeymapError("key sequence cannot be empty".to_string())); + } + if strokes.len() > 4 { + return Err(KeymapError( + "key sequence may contain at most four strokes".to_string(), + )); + } + Ok(Self(strokes)) + } + + fn first(&self) -> KeyStroke { + self.0[0] + } + + fn is_prefix_of(&self, other: &Self) -> bool { + self.0.len() < other.0.len() && other.0.starts_with(&self.0) + } +} + +impl fmt::Display for KeySequence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = self + .0 + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "); + formatter.write_str(&text) + } +} + +fn parse_key_code(source: &str) -> Result { + let normalized = source.to_ascii_lowercase(); + let named = match normalized.as_str() { + "backspace" => Some(KeyCode::Backspace), + "enter" => Some(KeyCode::Enter), + "left" => Some(KeyCode::Left), + "right" => Some(KeyCode::Right), + "up" => Some(KeyCode::Up), + "down" => Some(KeyCode::Down), + "home" => Some(KeyCode::Home), + "end" => Some(KeyCode::End), + "pageup" => Some(KeyCode::PageUp), + "pagedown" => Some(KeyCode::PageDown), + "tab" => Some(KeyCode::Tab), + "backtab" => Some(KeyCode::BackTab), + "delete" => Some(KeyCode::Delete), + "insert" => Some(KeyCode::Insert), + "esc" => Some(KeyCode::Esc), + "space" => Some(KeyCode::Char(' ')), + _ => None, + }; + if let Some(code) = named { + return Ok(code); + } + if let Some(number) = normalized + .strip_prefix('f') + .and_then(|value| value.parse::().ok()) + .filter(|number| (1..=24).contains(number)) + { + return Ok(KeyCode::F(number)); + } + let mut characters = source.chars(); + match (characters.next(), characters.next()) { + (Some(character), None) if !character.is_whitespace() && !character.is_control() => { + Ok(KeyCode::Char(character.to_ascii_lowercase())) + } + _ => Err(format!("unknown key `{source}`")), + } +} + +fn format_key_code(code: KeyCode) -> String { + match code { + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Enter => "enter".to_string(), + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + KeyCode::Home => "home".to_string(), + KeyCode::End => "end".to_string(), + KeyCode::PageUp => "pageup".to_string(), + KeyCode::PageDown => "pagedown".to_string(), + KeyCode::Tab => "tab".to_string(), + KeyCode::BackTab => "backtab".to_string(), + KeyCode::Delete => "delete".to_string(), + KeyCode::Insert => "insert".to_string(), + KeyCode::F(number) => format!("f{number}"), + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Char(character) => character.to_string(), + KeyCode::Esc => "esc".to_string(), + KeyCode::Null + | KeyCode::CapsLock + | KeyCode::ScrollLock + | KeyCode::NumLock + | KeyCode::PrintScreen + | KeyCode::Pause + | KeyCode::Menu + | KeyCode::KeypadBegin + | KeyCode::Media(_) + | KeyCode::Modifier(_) => { + unreachable!("unsupported key code in configurable stroke") + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Keymap { + bindings: HashMap>, +} + +impl Keymap { + pub(crate) fn built_in() -> Arc { + let mut bindings: HashMap> = + HashMap::new(); + for binding in configurable_legacy_bindings() { + bindings.entry(binding.context).or_default().push(( + KeySequence(vec![KeyStroke { + code: binding.key, + modifiers: binding.modifiers, + }]), + binding.action, + )); + } + Arc::new(Self { bindings }) + } + + #[cfg(test)] + pub(crate) fn resolve_single( + &self, + context: ShortcutContext, + event: KeyEvent, + ) -> Option { + self.resolve_in(ShortcutContext::Global, event) + .or_else(|| self.resolve_in(context, event)) + } + + fn resolve_in(&self, context: ShortcutContext, event: KeyEvent) -> Option { + self.bindings + .get(&context)? + .iter() + .find(|(sequence, _)| sequence.0.len() == 1 && sequence.first().matches(event)) + .map(|(_, action)| *action) + } + + #[cfg(test)] + pub(crate) fn binding_count(&self) -> usize { + self.bindings.values().map(Vec::len).sum() + } + + #[cfg(test)] + pub(crate) fn has_action(&self, action: ShortcutAction) -> bool { + self.bindings + .values() + .flatten() + .any(|(_, candidate)| *candidate == action) + } + + pub(crate) fn action_keys(&self, action: ShortcutAction) -> Vec { + self.sequences_for_action(action) + .into_iter() + .map(|sequence| sequence.to_string()) + .collect() + } + + pub(crate) fn action_uses_built_ins(&self, action: ShortcutAction) -> bool { + self.sequences_for_action(action) == Self::built_in().sequences_for_action(action) + } + + pub(crate) fn descriptor_keys(&self, descriptor: &ShortcutDescriptor) -> Option { + if descriptor.actions.is_empty() { + return Some(descriptor.legacy_keys.to_string()); + } + if descriptor.actions.iter().all(|action| { + self.sequences_for_action(*action) == Self::built_in().sequences_for_action(*action) + }) { + return Some(descriptor.legacy_keys.to_string()); + } + let keys = descriptor + .actions + .iter() + .flat_map(|action| self.sequences_for_action(*action)) + .map(|sequence| sequence.to_string()) + .collect::>(); + (!keys.is_empty()).then(|| keys.join(" / ")) + } + + fn sequences_for_action(&self, action: ShortcutAction) -> Vec { + sequences_for(&self.bindings, action) + } + + pub(super) fn cancel_action(&self, event: KeyEvent) -> Option { + self.resolve_in(ShortcutContext::Global, event) + .filter(|action| *action == ShortcutAction::Global(GlobalShortcut::Cancel)) + } + + pub(super) fn matching_sequences_in( + &self, + context: ShortcutContext, + prefix: &[KeyStroke], + ) -> Vec<(KeySequence, ShortcutAction)> { + let mut matches = self + .bindings + .get(&context) + .into_iter() + .flatten() + .filter(|(sequence, _)| sequence.0.starts_with(prefix)) + .cloned() + .collect::>(); + matches.sort_by_key(|(sequence, action)| { + ( + sequence.to_string(), + action.configurable_id().unwrap_or("").to_string(), + ) + }); + matches + } +} + +impl KeyStroke { + pub(super) fn from_event(event: KeyEvent) -> Option { + if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return None; + } + let (code, modifiers) = normalize_key_parts(event.code, event.modifiers); + Some(Self { code, modifiers }) + } +} + +impl KeySequence { + pub(super) fn len(&self) -> usize { + self.0.len() + } +} + +#[derive(Debug)] +pub(crate) struct KeymapError(String); + +impl fmt::Display for KeymapError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for KeymapError {} + +impl From for KeymapError { + fn from(error: serde_json::Error) -> Self { + Self(error.to_string()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct KeybindingsFile { + version: u32, + bindings: StrictBindings, +} + +struct StrictBindings(Vec<(String, Vec)>); + +impl<'de> Deserialize<'de> for StrictBindings { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct StrictBindingsVisitor; + + impl<'de> Visitor<'de> for StrictBindingsVisitor { + type Value = StrictBindings; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an action-to-key-sequences object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + while let Some((action, sequences)) = map.next_entry::>()? { + if !seen.insert(action.clone()) { + return Err(serde::de::Error::custom(format!( + "duplicate action `{action}`" + ))); + } + entries.push((action, sequences)); + } + Ok(StrictBindings(entries)) + } + } + + deserializer.deserialize_map(StrictBindingsVisitor) + } +} + +pub(crate) fn parse_keymap(bytes: &[u8]) -> Result, KeymapError> { + let file: KeybindingsFile = serde_json::from_slice(bytes)?; + if file.version != 1 { + return Err(KeymapError(format!( + "unsupported keybindings version {}", + file.version + ))); + } + + let built_in = Keymap::built_in(); + let mut bindings = built_in.bindings.clone(); + let mut replaced = HashSet::new(); + for (id, sources) in file.bindings.0 { + let action = + action_for_id(&id).ok_or_else(|| KeymapError(format!("unknown action `{id}`")))?; + replaced.insert(action); + if let Some(context_bindings) = bindings.get_mut(&action.context()) { + context_bindings.retain(|(_, candidate)| *candidate != action); + } + for source in sources { + let sequence = KeySequence::parse(&source).map_err(|error| { + KeymapError(format!("invalid binding `{source}` for `{id}`: {error}")) + })?; + bindings + .entry(action.context()) + .or_default() + .push((sequence, action)); + } + } + + validate_keymap(&bindings, &replaced)?; + Ok(Arc::new(Keymap { bindings })) +} + +fn validate_keymap( + bindings: &HashMap>, + replaced: &HashSet, +) -> Result<(), KeymapError> { + let cancel = ShortcutAction::Global(GlobalShortcut::Cancel); + let cancel_sequences = sequences_for(bindings, cancel); + if cancel_sequences.is_empty() + || cancel_sequences + .iter() + .any(|sequence| sequence.0.len() != 1) + { + return Err(KeymapError( + "global.cancel must retain at least one single-stroke binding".to_string(), + )); + } + + for (context, rows) in bindings { + for (sequence, action) in rows { + if (*context == ShortcutContext::Approval || *context == ShortcutContext::Global) + && sequence + .0 + .iter() + .any(|stroke| is_reserved_approval(*stroke)) + { + return Err(KeymapError(format!( + "binding `{sequence}` uses a reserved Approval stroke" + ))); + } + if replaced.contains(action) && *context == ShortcutContext::Global { + for stroke in &sequence.0 { + if !is_configurable_global_stroke(*stroke) { + return Err(KeymapError(format!( + "configurable Global binding `{sequence}` must use function keys or modified characters" + ))); + } + } + } + if *context != ShortcutContext::Approval { + for stroke in sequence.0.iter().take(sequence.0.len().saturating_sub(1)) { + if is_textual(*stroke) { + return Err(KeymapError(format!( + "non-final stroke `{stroke}` in `{sequence}` must be non-textual" + ))); + } + } + } + if *action != cancel + && sequence.0.iter().any(|stroke| { + cancel_sequences + .iter() + .any(|cancel| cancel.first() == *stroke) + }) + { + return Err(KeymapError(format!( + "binding `{sequence}` uses a reserved cancel stroke" + ))); + } + } + } + + for context in [ + ShortcutContext::Global, + ShortcutContext::Idle, + ShortcutContext::Running, + ShortcutContext::Approval, + ] { + let mut effective = bindings + .get(&ShortcutContext::Global) + .into_iter() + .flatten() + .cloned() + .collect::>(); + if context != ShortcutContext::Global { + effective.extend(bindings.get(&context).into_iter().flatten().cloned()); + } + effective.sort_by_key(|(sequence, action)| { + ( + sequence.to_string(), + action.configurable_id().unwrap_or("").to_string(), + ) + }); + for left in 0..effective.len() { + for right in (left + 1)..effective.len() { + let (left_sequence, left_action) = &effective[left]; + let (right_sequence, right_action) = &effective[right]; + if left_sequence == right_sequence { + return Err(KeymapError(format!( + "binding conflict in {context:?}: `{left_sequence}` maps to `{}` and `{}`", + left_action.configurable_id().unwrap_or("fixed"), + right_action.configurable_id().unwrap_or("fixed"), + ))); + } + if left_sequence.is_prefix_of(right_sequence) + || right_sequence.is_prefix_of(left_sequence) + { + return Err(KeymapError(format!( + "binding prefix conflict in {context:?}: `{left_sequence}` and `{right_sequence}`" + ))); + } + } + } + } + Ok(()) +} + +fn sequences_for( + bindings: &HashMap>, + action: ShortcutAction, +) -> Vec { + bindings + .get(&action.context()) + .into_iter() + .flatten() + .filter(|(_, candidate)| *candidate == action) + .map(|(sequence, _)| sequence.clone()) + .collect() +} + +fn is_textual(stroke: KeyStroke) -> bool { + matches!(stroke.code, KeyCode::Char(_)) + && !stroke.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) +} + +fn is_configurable_global_stroke(stroke: KeyStroke) -> bool { + matches!(stroke.code, KeyCode::F(1..=24)) + || matches!(stroke.code, KeyCode::Char(_)) + && stroke.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) +} + +fn is_reserved_approval(stroke: KeyStroke) -> bool { + ["1", "2", "3", "4", "y", "a", "shift+a", "n", "d"] + .into_iter() + .filter_map(|source| KeyStroke::parse(source).ok()) + .any(|reserved| reserved == stroke) +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + use crate::shortcuts::{GlobalShortcut, IdleShortcut, ShortcutAction, ShortcutContext}; + + use super::{KeyStroke, Keymap, parse_keymap}; + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + fn assert_error_contains(source: &[u8], expected: &str) { + let error = parse_keymap(source).unwrap_err().to_string(); + assert!( + error.contains(expected), + "expected {error:?} to contain {expected:?}", + ); + } + + #[test] + fn built_in_keymap_matches_all_configurable_legacy_bindings() { + let keymap = Keymap::built_in(); + for binding in crate::shortcuts::configurable_legacy_bindings() { + assert_eq!( + keymap.resolve_single(binding.context, binding.as_key_event()), + Some(binding.action), + "{:?} {:?} must keep {binding:?}", + binding.context, + binding.action, + ); + } + assert_eq!( + keymap.binding_count(), + crate::shortcuts::configurable_legacy_bindings().count(), + ); + } + + #[test] + fn key_strokes_parse_and_format_canonically() { + for (source, canonical) in [ + ("CTRL+X", "ctrl+x"), + ("alt+SHIFT+enter", "alt+shift+enter"), + ("BackTab", "backtab"), + ("space", "space"), + ("f24", "f24"), + ("ctrl+界", "ctrl+界"), + ] { + assert_eq!(KeyStroke::parse(source).unwrap().to_string(), canonical); + } + } + + #[test] + fn invalid_key_strokes_are_rejected() { + for source in [ + "", + "ctrl", + "ctrl+", + "ctrl+ctrl+x", + "wat+x", + "a+b", + "f25", + "\n", + ] { + assert!(KeyStroke::parse(source).is_err(), "{source:?}"); + } + } + + #[test] + fn normalization_keeps_c0_and_shifted_character_compatibility() { + let ctrl_j = KeyStroke::parse("ctrl+j").unwrap(); + assert!(ctrl_j.matches(KeyEvent::new(KeyCode::Char('\n'), KeyModifiers::NONE,))); + let shift_a = KeyStroke::parse("shift+a").unwrap(); + assert!(shift_a.matches(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE,))); + } + + #[test] + fn omitted_actions_inherit_and_present_actions_replace_defaults() { + let keymap = parse_keymap( + br#"{ + "version": 1, + "bindings": { + "idle.submit": ["ctrl+s"], + "idle.backtrack": [] + } + }"#, + ) + .unwrap(); + + assert_eq!( + keymap.resolve_single( + ShortcutContext::Idle, + key(KeyCode::Char('s'), KeyModifiers::CONTROL), + ), + Some(ShortcutAction::Idle(IdleShortcut::Submit)), + ); + assert_eq!( + keymap.resolve_single( + ShortcutContext::Idle, + key(KeyCode::Enter, KeyModifiers::NONE), + ), + None, + ); + assert!(!keymap.has_action(ShortcutAction::Idle(IdleShortcut::Backtrack))); + assert!(keymap.has_action(ShortcutAction::Global(GlobalShortcut::Cancel))); + } + + #[test] + fn rejects_unknown_schema_versions_fields_actions_and_duplicate_actions() { + assert_error_contains( + br#"{"version":2,"bindings":{}}"#, + "unsupported keybindings version 2", + ); + assert_error_contains( + br#"{"version":1,"extra":true,"bindings":{}}"#, + "unknown field `extra`", + ); + assert_error_contains( + br#"{"version":1,"bindings":{"idle.missing":["x"]}}"#, + "unknown action `idle.missing`", + ); + assert_error_contains( + br#"{"version":1,"bindings":{"idle.submit":["enter"],"idle.submit":["ctrl+s"]}}"#, + "duplicate action `idle.submit`", + ); + } + + #[test] + fn every_documented_action_id_is_accepted_independently_of_default_bindings() { + for action in crate::shortcuts::configurable_actions() { + let id = action + .configurable_id() + .expect("registry contains configurable actions only"); + let sequences = if id == "global.cancel" { + r#"["ctrl+c"]"# + } else { + "[]" + }; + let source = format!(r#"{{"version":1,"bindings":{{"{id}":{sequences}}}}}"#); + parse_keymap(source.as_bytes()) + .unwrap_or_else(|error| panic!("{id} must parse: {error}")); + } + } + + #[test] + fn idle_scroll_actions_parse_and_resolve_as_chords() { + let keymap = parse_keymap( + br#"{ + "version": 1, + "bindings": { + "idle.scroll-up": ["ctrl+x ctrl+r"], + "idle.scroll-down": ["ctrl+x ctrl+t"] + } + }"#, + ) + .unwrap(); + + assert!(keymap.has_action(ShortcutAction::Idle(IdleShortcut::ScrollUp))); + assert!(keymap.has_action(ShortcutAction::Idle(IdleShortcut::ScrollDown))); + } + + #[test] + fn validates_conflicts_prefixes_and_reserved_strokes() { + for (source, expected) in [ + ( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+x"],"idle.newline":["ctrl+x"]}}"# + .as_slice(), + "conflict", + ), + ( + br#"{"version":1,"bindings":{"global.clear-screen":["ctrl+x"],"idle.submit":["ctrl+x"]}}"#, + "conflict", + ), + ( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+x"],"idle.newline":["ctrl+x ctrl+j"]}}"#, + "prefix", + ), + ( + br#"{"version":1,"bindings":{"global.cancel":[]}}"#, + "single-stroke", + ), + ( + br#"{"version":1,"bindings":{"global.cancel":["ctrl+x ctrl+c"]}}"#, + "single-stroke", + ), + ( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+x ctrl+c"]}}"#, + "reserved cancel", + ), + ( + br#"{"version":1,"bindings":{"global.clear-screen":["esc"]}}"#, + "configurable Global", + ), + ( + br#"{"version":1,"bindings":{"global.clear-screen":["shift+x"]}}"#, + "configurable Global", + ), + ( + br#"{"version":1,"bindings":{"approval.confirm":["1"]}}"#, + "reserved Approval", + ), + ( + br#"{"version":1,"bindings":{"global.clear-screen":["ctrl+x a"]}}"#, + "reserved Approval", + ), + ( + br#"{"version":1,"bindings":{"idle.submit":["g g"]}}"#, + "non-textual", + ), + ( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+x ctrl+a ctrl+b ctrl+d ctrl+e"]}}"#, + "at most four", + ), + ] { + assert_error_contains(source, expected); + } + } + + #[test] + fn permits_cross_context_reuse_approval_text_chords_and_four_strokes() { + let keymap = parse_keymap( + br#"{ + "version": 1, + "bindings": { + "idle.submit": ["ctrl+x ctrl+s"], + "running.submit-queued": ["ctrl+x ctrl+s"], + "approval.confirm": ["g g"], + "idle.newline": ["ctrl+x ctrl+a ctrl+b ctrl+n"] + } + }"#, + ) + .unwrap(); + + assert!(keymap.has_action(ShortcutAction::Idle(IdleShortcut::Submit))); + } + + #[test] + fn built_in_descriptor_keys_match_every_legacy_help_string() { + let keymap = Keymap::built_in(); + + for (descriptor, hint) in + crate::shortcuts::shortcut_descriptors().zip(crate::shortcuts::SHORTCUT_HINTS) + { + assert_eq!(descriptor.scope, hint.scope); + assert_eq!( + keymap.descriptor_keys(descriptor).as_deref(), + Some(descriptor.legacy_keys), + "{}", + descriptor.label, + ); + } + } + + #[test] + fn descriptor_keys_change_only_with_their_referenced_actions() { + let keymap = parse_keymap( + br#"{ + "version": 1, + "bindings": { + "idle.submit": ["ctrl+s"], + "idle.backtrack": [] + } + }"#, + ) + .unwrap(); + + for descriptor in crate::shortcuts::shortcut_descriptors() { + let keys = keymap.descriptor_keys(descriptor); + match descriptor.label { + "send message" => assert_eq!(keys.as_deref(), Some("ctrl+s")), + "backtrack previous prompt" => assert_eq!(keys, None), + _ => assert_eq!(keys.as_deref(), Some(descriptor.legacy_keys)), + } + } + } + + #[test] + fn descriptor_keys_format_replacements_and_chords_canonically() { + let keymap = parse_keymap( + br#"{ + "version": 1, + "bindings": { + "idle.history-previous": ["ctrl+x ctrl+p", "alt+p"], + "idle.history-next": ["ctrl+x ctrl+n"] + } + }"#, + ) + .unwrap(); + let descriptor = crate::shortcuts::shortcut_descriptors() + .find(|descriptor| descriptor.label == "previous or next prompt") + .unwrap(); + + assert_eq!( + keymap.descriptor_keys(descriptor).as_deref(), + Some("ctrl+x ctrl+p / alt+p / ctrl+x ctrl+n"), + ); + } + + #[test] + fn readmes_document_keybindings_path_schema_and_reload() { + for (name, readme) in [ + ("README.md", include_str!("../../../../README.md")), + ( + "README.zh-CN.md", + include_str!("../../../../README.zh-CN.md"), + ), + ] { + for required in [ + "~/.orca/keybindings.json", + "ORCA_HOME", + "\"version\": 1", + "\"bindings\"", + "global.cancel", + "idle.submit", + "running.interrupt", + "approval.confirm", + "1 second", + "live reload", + "last-known-good", + ] { + assert!( + readme.contains(required), + "{name} must document {required:?}", + ); + } + } + } +} diff --git a/crates/orca-tui/src/keybindings/mod.rs b/crates/orca-tui/src/keybindings/mod.rs new file mode 100644 index 00000000..9d015d9a --- /dev/null +++ b/crates/orca-tui/src/keybindings/mod.rs @@ -0,0 +1,12 @@ +mod config; +mod reload; +mod runtime; + +pub(crate) use config::Keymap; +#[cfg(test)] +pub(crate) use config::parse_keymap; +pub(crate) use reload::{KeymapReloader, keybindings_location, keybindings_path}; +pub(crate) use runtime::{ + InputOwnerFingerprint, InvocationOrigin, KeymapRuntime, ModalOwner, ReloadOutcome, + ShortcutInvocation, ShortcutResolution, +}; diff --git a/crates/orca-tui/src/keybindings/reload.rs b/crates/orca-tui/src/keybindings/reload.rs new file mode 100644 index 00000000..cb24845e --- /dev/null +++ b/crates/orca-tui/src/keybindings/reload.rs @@ -0,0 +1,387 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crossbeam_channel as mpsc; + +use crate::diagnostics::KeybindingsLocation; + +pub(crate) const MAX_KEYBINDINGS_BYTES: usize = 64 * 1024; +const RELOAD_INTERVAL: Duration = Duration::from_millis(500); +const ORCA_HOME_ENV: &str = "ORCA_HOME"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum FileObservation { + Missing, + Bytes(Vec), + Rejected(String), +} + +type LoaderFn = Arc FileObservation + Send + Sync + 'static>; + +pub(crate) fn keybindings_path() -> Option { + keybindings_directory_from_sources(std::env::var_os(ORCA_HOME_ENV), dirs::home_dir()) + .map(|(directory, _)| directory) + .map(|directory| directory.join("keybindings.json")) +} + +pub(crate) fn keybindings_location() -> KeybindingsLocation { + keybindings_location_from_sources(std::env::var_os(ORCA_HOME_ENV), dirs::home_dir()) +} + +fn keybindings_location_from_sources( + orca_home: Option, + home: Option, +) -> KeybindingsLocation { + keybindings_directory_from_sources(orca_home, home) + .map(|(_, location)| location) + .unwrap_or(KeybindingsLocation::Unavailable) +} + +fn keybindings_directory_from_sources( + orca_home: Option, + home: Option, +) -> Option<(PathBuf, KeybindingsLocation)> { + orca_home + .map(|directory| (PathBuf::from(directory), KeybindingsLocation::OrcaHome)) + .or_else(|| { + home.map(|directory| (directory.join(".orca"), KeybindingsLocation::DefaultHome)) + }) +} + +pub(crate) fn load_observation(path: &Path) -> FileObservation { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return FileObservation::Missing, + Err(error) => { + return FileObservation::Rejected(format!( + "cannot inspect {}: {error}", + path.display() + )); + } + }; + if metadata.file_type().is_symlink() { + return FileObservation::Rejected(format!("{} is a symbolic link", path.display())); + } + if !metadata.is_file() { + return FileObservation::Rejected(format!("{} is not a regular file", path.display())); + } + + let file = match open_regular_file(path) { + Ok(file) => file, + Err(error) => { + return FileObservation::Rejected(format!("cannot open {}: {error}", path.display())); + } + }; + let opened_metadata = match file.metadata() { + Ok(metadata) if metadata.is_file() => metadata, + Ok(_) => { + return FileObservation::Rejected(format!( + "{} did not open as a regular file", + path.display() + )); + } + Err(error) => { + return FileObservation::Rejected(format!( + "cannot inspect opened {}: {error}", + path.display() + )); + } + }; + let mut bytes = + Vec::with_capacity(opened_metadata.len().min(MAX_KEYBINDINGS_BYTES as u64) as usize); + if let Err(error) = file + .take((MAX_KEYBINDINGS_BYTES + 1) as u64) + .read_to_end(&mut bytes) + { + return FileObservation::Rejected(format!("cannot read {}: {error}", path.display())); + } + if bytes.len() > MAX_KEYBINDINGS_BYTES { + return FileObservation::Rejected(format!( + "{} exceeds the 64 KiB keybindings limit", + path.display() + )); + } + FileObservation::Bytes(bytes) +} + +fn open_regular_file(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + } + options.open(path) +} + +pub(crate) struct KeymapReloader { + request_tx: mpsc::Sender<()>, + result_rx: mpsc::Receiver, + join: Option>, + next_poll_at: Instant, +} + +impl KeymapReloader { + pub(crate) fn start(path: PathBuf, now: Instant) -> Self { + Self::start_with_loader(path, now, Arc::new(load_observation)) + } + + fn start_with_loader(path: PathBuf, now: Instant, loader: LoaderFn) -> Self { + let (request_tx, request_rx) = mpsc::bounded(1); + let (result_tx, result_rx) = mpsc::bounded(1); + let worker_result_rx = result_rx.clone(); + let join = thread::Builder::new() + .name("orca-keybindings-reload".to_string()) + .spawn(move || { + while request_rx.recv().is_ok() { + let observation = loader(&path); + match result_tx.try_send(observation) { + Ok(()) => {} + Err(mpsc::TrySendError::Full(observation)) => { + let _ = worker_result_rx.try_recv(); + let _ = result_tx.try_send(observation); + } + Err(mpsc::TrySendError::Disconnected(_)) => break, + } + } + }) + .expect("keybindings reload worker must spawn"); + Self { + request_tx, + result_rx, + join: Some(join), + next_poll_at: now, + } + } + + pub(crate) fn request_reload(&mut self, now: Instant) -> bool { + if now < self.next_poll_at { + return false; + } + self.next_poll_at = now + RELOAD_INTERVAL; + matches!(self.request_tx.try_send(()), Ok(())) + } + + pub(crate) fn try_recv(&self) -> Option { + self.result_rx.try_recv().ok() + } +} + +impl Drop for KeymapReloader { + fn drop(&mut self) { + let (replacement, _receiver) = mpsc::bounded(0); + let request_tx = std::mem::replace(&mut self.request_tx, replacement); + drop(request_tx); + if self.join.as_ref().is_some_and(JoinHandle::is_finished) + && let Some(join) = self.join.take() + { + let _ = join.join(); + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + use crate::diagnostics::KeybindingsLocation; + use crate::keybindings::runtime::{ + InputOwnerFingerprint, KeymapRuntime, ModalOwner, ReloadOutcome, ShortcutResolution, + }; + use crate::shortcuts::{IdleShortcut, ShortcutAction, ShortcutContext}; + use crate::types::PanelMode; + + use super::{ + FileObservation, KeymapReloader, MAX_KEYBINDINGS_BYTES, keybindings_location_from_sources, + keybindings_path, load_observation, + }; + + fn idle_owner() -> InputOwnerFingerprint { + InputOwnerFingerprint { + context: ShortcutContext::Idle, + modal: ModalOwner::None, + panel: PanelMode::Conversation, + vim_mode: None, + } + } + + fn ctrl(character: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL) + } + + #[test] + fn keybindings_path_uses_orca_home() { + let _env = crate::test_support::lock_process_env(); + let home = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var("ORCA_HOME", home.path()) }; + assert_eq!( + keybindings_path().unwrap(), + home.path().join("keybindings.json"), + ); + unsafe { std::env::remove_var("ORCA_HOME") }; + } + + #[test] + fn keybindings_location_distinguishes_orca_home_default_home_and_unavailable() { + assert_eq!( + keybindings_location_from_sources(Some("custom".into()), Some("home".into())), + KeybindingsLocation::OrcaHome, + ); + assert_eq!( + keybindings_location_from_sources(None, Some("home".into())), + KeybindingsLocation::DefaultHome, + ); + assert_eq!( + keybindings_location_from_sources(None, None), + KeybindingsLocation::Unavailable, + ); + } + + #[test] + fn loader_reads_only_limit_plus_sentinel() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("keybindings.json"); + fs::write(&path, vec![b'x'; MAX_KEYBINDINGS_BYTES + 1]).unwrap(); + + assert!(matches!( + load_observation(&path), + FileObservation::Rejected(ref error) if error.contains("64 KiB"), + )); + } + + #[test] + fn missing_directory_and_symlink_observations_are_bounded() { + let directory = tempfile::tempdir().unwrap(); + assert_eq!( + load_observation(&directory.path().join("missing")), + FileObservation::Missing, + ); + assert!(matches!( + load_observation(directory.path()), + FileObservation::Rejected(ref error) if error.contains("regular file"), + )); + + #[cfg(unix)] + { + let target = directory.path().join("target.json"); + let link = directory.path().join("keybindings.json"); + fs::write(&target, br#"{"version":1,"bindings":{}}"#).unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(matches!( + load_observation(&link), + FileObservation::Rejected(ref error) if error.contains("symbolic link"), + )); + } + } + + #[test] + fn runtime_applies_valid_rejects_invalid_deduplicates_and_restores_defaults() { + let mut runtime = KeymapRuntime::new(crate::keybindings::config::Keymap::built_in()); + let valid = FileObservation::Bytes( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+s"]}}"#.to_vec(), + ); + assert_eq!( + runtime.apply_observation(valid.clone()), + ReloadOutcome::Applied + ); + assert_eq!( + runtime.resolve(idle_owner(), ctrl('s'), Instant::now()), + ShortcutResolution::Action(crate::keybindings::runtime::ShortcutInvocation::key( + ShortcutAction::Idle(IdleShortcut::Submit), + ctrl('s'), + ),), + ); + assert_eq!( + runtime.apply_observation(FileObservation::Bytes(b"{".to_vec())), + ReloadOutcome::Rejected( + "keybindings reload rejected: EOF while parsing an object".to_string() + ), + ); + assert_eq!( + runtime.apply_observation(FileObservation::Bytes(b"{".to_vec())), + ReloadOutcome::Unchanged, + ); + assert_eq!( + runtime.apply_observation(valid), + ReloadOutcome::Unchanged, + "last-known-good map remains active after a rejected observation", + ); + assert_eq!( + runtime.apply_observation(FileObservation::Missing), + ReloadOutcome::RestoredDefaults, + ); + } + + #[test] + fn applied_reload_clears_pending_chord() { + let map = crate::keybindings::config::parse_keymap( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+x ctrl+s"]}}"#, + ) + .unwrap(); + let mut runtime = KeymapRuntime::new(map); + assert_eq!( + runtime.resolve(idle_owner(), ctrl('x'), Instant::now()), + ShortcutResolution::Pending, + ); + + assert_eq!( + runtime.apply_observation(FileObservation::Bytes( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+s"]}}"#.to_vec() + )), + ReloadOutcome::Applied, + ); + assert!(!runtime.has_pending_chord()); + } + + #[test] + fn reloader_caps_requests_and_delivers_latest_observation() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("keybindings.json"); + fs::write(&path, br#"{"version":1,"bindings":{}}"#).unwrap(); + let now = Instant::now(); + let mut reloader = KeymapReloader::start(path, now); + + assert!(reloader.request_reload(now)); + assert!(!reloader.request_reload(now + Duration::from_millis(499))); + let deadline = Instant::now() + Duration::from_secs(2); + let observation = loop { + if let Some(observation) = reloader.try_recv() { + break observation; + } + assert!(Instant::now() < deadline, "reload result timed out"); + std::thread::yield_now(); + }; + assert!(matches!(observation, FileObservation::Bytes(_))); + assert!(reloader.request_reload(now + Duration::from_millis(500))); + } + + #[test] + fn blocked_loader_does_not_block_request_or_drop() { + let (entered_tx, entered_rx) = crossbeam_channel::bounded(1); + let (_release_tx, release_rx) = crossbeam_channel::bounded::<()>(0); + let loader = Arc::new(move |_path: &Path| { + let _ = entered_tx.send(()); + let _ = release_rx.recv(); + FileObservation::Missing + }); + let now = Instant::now(); + let mut reloader = KeymapReloader::start_with_loader("blocked".into(), now, loader); + assert!(reloader.request_reload(now)); + entered_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + + let started = Instant::now(); + drop(reloader); + assert!(started.elapsed() < Duration::from_millis(100)); + } +} diff --git a/crates/orca-tui/src/keybindings/runtime.rs b/crates/orca-tui/src/keybindings/runtime.rs new file mode 100644 index 00000000..594af1dd --- /dev/null +++ b/crates/orca-tui/src/keybindings/runtime.rs @@ -0,0 +1,788 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyEvent, KeyEventKind}; + +use crate::shortcuts::{ShortcutAction, ShortcutContext}; +use crate::types::PanelMode; +use crate::vim::VimMode; + +use super::config::{KeyStroke, Keymap}; +use super::reload::FileObservation; + +const CHORD_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum ModalOwner { + None, + TranscriptSearch, + Shortcuts, + SlashMenu, + MentionMenu, + WorkflowPanel, + Setup, + SessionPicker, + Approval, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) struct InputOwnerFingerprint { + pub(crate) context: ShortcutContext, + pub(crate) modal: ModalOwner, + pub(crate) panel: PanelMode, + pub(crate) vim_mode: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InvocationOrigin { + Key(KeyEvent), + Chord, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ShortcutInvocation { + pub(crate) action: ShortcutAction, + pub(crate) origin: InvocationOrigin, +} + +impl ShortcutInvocation { + pub(crate) const fn key(action: ShortcutAction, event: KeyEvent) -> Self { + Self { + action, + origin: InvocationOrigin::Key(event), + } + } + + pub(crate) const fn chord(action: ShortcutAction) -> Self { + Self { + action, + origin: InvocationOrigin::Chord, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ShortcutResolution { + NoMatch, + RetryCurrentKey, + Pending, + Action(ShortcutInvocation), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ReloadOutcome { + Unchanged, + Applied, + RestoredDefaults, + Rejected(String), +} + +#[derive(Clone, Debug)] +struct PendingChord { + owner: InputOwnerFingerprint, + scope: PendingScope, + strokes: Vec, + deadline: Instant, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PendingScope { + Global, + Context, + GlobalAndContext, +} + +pub(crate) struct KeymapRuntime { + keymap: Arc, + generation: u64, + pending: Option, + last_observation: Option, + active_bytes: Option>, + last_reload_rejected: bool, +} + +impl KeymapRuntime { + pub(crate) fn new(keymap: Arc) -> Self { + Self { + keymap, + generation: 0, + pending: None, + last_observation: None, + active_bytes: None, + last_reload_rejected: false, + } + } + + #[cfg(test)] + pub(crate) fn resolve( + &mut self, + owner: InputOwnerFingerprint, + event: KeyEvent, + now: Instant, + ) -> ShortcutResolution { + match self.resolve_cancel(event) { + ShortcutResolution::NoMatch => {} + resolution => return resolution, + } + match self.advance_pending(owner, event, now) { + ShortcutResolution::NoMatch => {} + resolution => return resolution, + } + match self.resolve_new_global(owner, event, now) { + ShortcutResolution::NoMatch => self.resolve_new_context(owner, event, now), + resolution => resolution, + } + } + + pub(crate) fn resolve_cancel(&mut self, event: KeyEvent) -> ShortcutResolution { + if matches!(event.kind, KeyEventKind::Release) { + return ShortcutResolution::NoMatch; + } + let Some(action) = self.keymap.cancel_action(event) else { + return ShortcutResolution::NoMatch; + }; + self.pending = None; + ShortcutResolution::Action(ShortcutInvocation::key(action, event)) + } + + pub(crate) fn advance_pending( + &mut self, + owner: InputOwnerFingerprint, + event: KeyEvent, + now: Instant, + ) -> ShortcutResolution { + if matches!(event.kind, KeyEventKind::Release) { + return ShortcutResolution::NoMatch; + } + if self + .pending + .as_ref() + .is_some_and(|pending| pending.owner != owner || now > pending.deadline) + { + self.pending = None; + return ShortcutResolution::NoMatch; + } + let Some(pending) = self.pending.as_ref() else { + return ShortcutResolution::NoMatch; + }; + let Some(stroke) = KeyStroke::from_event(event) else { + return ShortcutResolution::NoMatch; + }; + let mut prefix = pending.strokes.clone(); + prefix.push(stroke); + let context = match pending.scope { + PendingScope::Global => ShortcutContext::Global, + PendingScope::Context => owner.context, + PendingScope::GlobalAndContext => owner.context, + }; + let mut matches = self.keymap.matching_sequences_in(context, &prefix); + if pending.scope == PendingScope::GlobalAndContext { + matches.extend( + self.keymap + .matching_sequences_in(ShortcutContext::Global, &prefix), + ); + matches.sort_by_key(|(sequence, action)| { + ( + sequence.to_string(), + action.configurable_id().unwrap_or("").to_string(), + ) + }); + } + if matches.is_empty() { + self.pending = None; + return ShortcutResolution::RetryCurrentKey; + } + if let Some((_, action)) = matches + .iter() + .find(|(sequence, _)| sequence.len() == prefix.len()) + { + let action = *action; + self.pending = None; + return ShortcutResolution::Action(ShortcutInvocation::chord(action)); + } + let pending = self.pending.as_mut().expect("pending chord exists"); + pending.strokes = prefix; + pending.deadline = now + CHORD_TIMEOUT; + ShortcutResolution::Pending + } + + pub(crate) fn resolve_new_global( + &mut self, + owner: InputOwnerFingerprint, + event: KeyEvent, + now: Instant, + ) -> ShortcutResolution { + let Some(stroke) = KeyStroke::from_event(event) else { + return ShortcutResolution::NoMatch; + }; + let global_matches = self + .keymap + .matching_sequences_in(ShortcutContext::Global, &[stroke]); + if let Some((_, action)) = global_matches + .iter() + .find(|(sequence, _)| sequence.len() == 1) + { + return ShortcutResolution::Action(ShortcutInvocation::key(*action, event)); + } + if global_matches.is_empty() { + return ShortcutResolution::NoMatch; + } + let carries_context = matches!(owner.modal, ModalOwner::None | ModalOwner::Approval) + && owner.context != ShortcutContext::Global + && !self + .keymap + .matching_sequences_in(owner.context, &[stroke]) + .is_empty(); + self.pending = Some(PendingChord { + owner, + scope: if carries_context { + PendingScope::GlobalAndContext + } else { + PendingScope::Global + }, + strokes: vec![stroke], + deadline: now + CHORD_TIMEOUT, + }); + ShortcutResolution::Pending + } + + pub(crate) fn resolve_new_context( + &mut self, + owner: InputOwnerFingerprint, + event: KeyEvent, + now: Instant, + ) -> ShortcutResolution { + if owner.context == ShortcutContext::Global { + return ShortcutResolution::NoMatch; + } + self.resolve_new_in(owner, owner.context, PendingScope::Context, event, now) + } + + fn resolve_new_in( + &mut self, + owner: InputOwnerFingerprint, + context: ShortcutContext, + scope: PendingScope, + event: KeyEvent, + now: Instant, + ) -> ShortcutResolution { + if self.pending.is_some() { + return ShortcutResolution::NoMatch; + } + let Some(stroke) = KeyStroke::from_event(event) else { + return ShortcutResolution::NoMatch; + }; + let matches = self.keymap.matching_sequences_in(context, &[stroke]); + if let Some((_, action)) = matches.iter().find(|(sequence, _)| sequence.len() == 1) { + return ShortcutResolution::Action(ShortcutInvocation::key(*action, event)); + } + if matches.is_empty() { + return ShortcutResolution::NoMatch; + } + self.pending = Some(PendingChord { + owner, + scope, + strokes: vec![stroke], + deadline: now + CHORD_TIMEOUT, + }); + ShortcutResolution::Pending + } + + pub(crate) fn clear_for_non_key(&mut self) { + self.pending = None; + } + + pub(crate) fn clear_for_suspend(&mut self) { + self.pending = None; + } + + pub(crate) fn next_deadline(&self) -> Option { + self.pending.as_ref().map(|pending| pending.deadline) + } + + pub(crate) fn expire_pending(&mut self, now: Instant) -> bool { + if self + .pending + .as_ref() + .is_some_and(|pending| now > pending.deadline) + { + self.pending = None; + true + } else { + false + } + } + + #[cfg(test)] + pub(crate) fn has_pending_chord(&self) -> bool { + self.pending.is_some() + } + + pub(crate) fn install(&mut self, keymap: Arc) { + self.keymap = keymap; + self.generation = self.generation.wrapping_add(1); + self.pending = None; + } + + pub(crate) const fn generation(&self) -> u64 { + self.generation + } + + pub(crate) fn last_observation_rejected(&self) -> bool { + self.last_reload_rejected + } + + pub(crate) fn keymap(&self) -> Arc { + Arc::clone(&self.keymap) + } + + pub(crate) fn apply_observation(&mut self, observation: FileObservation) -> ReloadOutcome { + if self.last_observation.as_ref() == Some(&observation) { + return ReloadOutcome::Unchanged; + } + self.last_observation = Some(observation.clone()); + match observation { + FileObservation::Missing => { + self.last_reload_rejected = false; + if self.active_bytes.is_none() { + return ReloadOutcome::Unchanged; + } + self.active_bytes = None; + self.install(Keymap::built_in()); + ReloadOutcome::RestoredDefaults + } + FileObservation::Rejected(error) => { + self.last_reload_rejected = true; + ReloadOutcome::Rejected(format!("keybindings reload rejected: {error}")) + } + FileObservation::Bytes(bytes) => { + if self.active_bytes.as_ref() == Some(&bytes) { + self.last_reload_rejected = false; + return ReloadOutcome::Unchanged; + } + match super::config::parse_keymap(&bytes) { + Ok(keymap) => { + self.last_reload_rejected = false; + self.active_bytes = Some(bytes); + self.install(keymap); + ReloadOutcome::Applied + } + Err(error) => { + self.last_reload_rejected = true; + ReloadOutcome::Rejected(format!( + "keybindings reload rejected: {}", + stable_parse_error(&error.to_string()) + )) + } + } + } + } + } +} + +fn stable_parse_error(error: &str) -> &str { + error.split(" at line ").next().unwrap_or(error) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + + use crate::shortcuts::{GlobalShortcut, IdleShortcut, ShortcutAction, ShortcutContext}; + use crate::types::PanelMode; + use crate::vim::VimMode; + + use super::{ + FileObservation, InputOwnerFingerprint, KeymapRuntime, ModalOwner, ReloadOutcome, + ShortcutInvocation, ShortcutResolution, + }; + + fn ctrl(character: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL) + } + + fn idle_owner() -> InputOwnerFingerprint { + InputOwnerFingerprint { + context: ShortcutContext::Idle, + modal: ModalOwner::None, + panel: PanelMode::Conversation, + vim_mode: None, + } + } + + fn runtime_with(bindings: &str) -> KeymapRuntime { + let source = format!(r#"{{"version":1,"bindings":{bindings}}}"#); + KeymapRuntime::new(crate::keybindings::config::parse_keymap(source.as_bytes()).unwrap()) + } + + #[test] + fn exact_chord_emits_once_and_resets() { + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+s"]}"#); + let now = Instant::now(); + let owner = idle_owner(); + + assert_eq!( + runtime.resolve(owner, ctrl('x'), now), + ShortcutResolution::Pending, + ); + assert_eq!( + runtime.resolve(owner, ctrl('s'), now + Duration::from_millis(10)), + ShortcutResolution::Action(ShortcutInvocation::chord(ShortcutAction::Idle( + IdleShortcut::Submit, + ))), + ); + assert!(!runtime.has_pending_chord()); + } + + #[test] + fn two_three_and_four_stroke_chords_complete() { + for (sequence, keys) in [ + ("ctrl+x ctrl+s", vec!['x', 's']), + ("ctrl+x ctrl+a ctrl+s", vec!['x', 'a', 's']), + ("ctrl+x ctrl+a ctrl+b ctrl+s", vec!['x', 'a', 'b', 's']), + ] { + let source = format!(r#"{{"idle.submit":["{sequence}"]}}"#); + let mut runtime = runtime_with(&source); + let now = Instant::now(); + for (index, key) in keys.iter().enumerate() { + let resolution = runtime.resolve( + idle_owner(), + ctrl(*key), + now + Duration::from_millis(index as u64), + ); + if index + 1 == keys.len() { + assert!(matches!(resolution, ShortcutResolution::Action(_))); + } else { + assert_eq!(resolution, ShortcutResolution::Pending); + } + } + } + } + + #[test] + fn mismatch_reroutes_current_key_once() { + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+s"]}"#); + let now = Instant::now(); + assert_eq!( + runtime.resolve(idle_owner(), ctrl('x'), now), + ShortcutResolution::Pending, + ); + assert_eq!( + runtime.resolve(idle_owner(), ctrl('f'), now + Duration::from_millis(1)), + ShortcutResolution::RetryCurrentKey, + ); + assert!(!runtime.has_pending_chord()); + } + + #[test] + fn accepted_intermediate_resets_deadline_and_expiry_reroutes_normally() { + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+a ctrl+s"]}"#); + let now = Instant::now(); + runtime.resolve(idle_owner(), ctrl('x'), now); + let first_deadline = runtime.next_deadline().unwrap(); + runtime.resolve(idle_owner(), ctrl('a'), now + Duration::from_millis(900)); + let second_deadline = runtime.next_deadline().unwrap(); + assert!(second_deadline > first_deadline); + + assert_eq!( + runtime.resolve( + idle_owner(), + ctrl('s'), + second_deadline + Duration::from_millis(1), + ), + ShortcutResolution::NoMatch, + ); + assert!(!runtime.has_pending_chord()); + } + + #[test] + fn explicit_expiry_clears_deadline_without_input() { + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+s"]}"#); + let now = Instant::now(); + assert_eq!( + runtime.resolve_new_context(idle_owner(), ctrl('x'), now), + ShortcutResolution::Pending, + ); + let deadline = runtime.next_deadline().unwrap(); + + assert!(!runtime.expire_pending(deadline)); + assert!(runtime.next_deadline().is_some()); + assert!(runtime.expire_pending(deadline + Duration::from_millis(1))); + assert_eq!(runtime.next_deadline(), None); + } + + #[test] + fn release_is_ignored_and_repeat_advances() { + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+s"]}"#); + let now = Instant::now(); + let release = KeyEvent { + kind: KeyEventKind::Release, + ..ctrl('x') + }; + assert_eq!( + runtime.resolve(idle_owner(), release, now), + ShortcutResolution::NoMatch, + ); + let repeat = KeyEvent { + kind: KeyEventKind::Repeat, + ..ctrl('x') + }; + assert_eq!( + runtime.resolve(idle_owner(), repeat, now), + ShortcutResolution::Pending, + ); + } + + #[test] + fn cancel_clears_pending_before_action_and_following_key_is_normal() { + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+s"]}"#); + let now = Instant::now(); + runtime.resolve(idle_owner(), ctrl('x'), now); + assert_eq!( + runtime.resolve(idle_owner(), ctrl('c'), now + Duration::from_millis(1)), + ShortcutResolution::Action(ShortcutInvocation::key( + ShortcutAction::Global(GlobalShortcut::Cancel), + ctrl('c'), + )), + ); + assert!(!runtime.has_pending_chord()); + assert_eq!( + runtime.resolve(idle_owner(), ctrl('s'), now + Duration::from_millis(2)), + ShortcutResolution::NoMatch, + ); + } + + #[test] + fn owner_and_explicit_boundaries_clear_pending() { + let now = Instant::now(); + for modal in [ + ModalOwner::TranscriptSearch, + ModalOwner::Shortcuts, + ModalOwner::SlashMenu, + ModalOwner::MentionMenu, + ModalOwner::WorkflowPanel, + ModalOwner::Setup, + ModalOwner::SessionPicker, + ModalOwner::Approval, + ] { + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+s"]}"#); + runtime.resolve(idle_owner(), ctrl('x'), now); + let changed_owner = InputOwnerFingerprint { + modal, + vim_mode: Some(VimMode::Normal), + panel: PanelMode::Workflows, + ..idle_owner() + }; + assert_eq!( + runtime.resolve(changed_owner, ctrl('s'), now + Duration::from_millis(1)), + ShortcutResolution::NoMatch, + "{modal:?}", + ); + } + + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+s"]}"#); + runtime.resolve(idle_owner(), ctrl('x'), now + Duration::from_millis(2)); + runtime.clear_for_non_key(); + assert!(!runtime.has_pending_chord()); + runtime.resolve(idle_owner(), ctrl('x'), now + Duration::from_millis(3)); + runtime.clear_for_suspend(); + assert!(!runtime.has_pending_chord()); + } + + #[test] + fn replacing_keymap_clears_pending_and_advances_generation() { + let mut runtime = runtime_with(r#"{"idle.submit":["ctrl+x ctrl+s"]}"#); + let now = Instant::now(); + runtime.resolve(idle_owner(), ctrl('x'), now); + let generation = runtime.generation(); + runtime.install(crate::keybindings::config::Keymap::built_in()); + + assert!(!runtime.has_pending_chord()); + assert_eq!(runtime.generation(), generation + 1); + } + + #[test] + fn unchanged_reload_exposes_whether_the_observation_is_still_rejected() { + let mut runtime = KeymapRuntime::new(crate::keybindings::config::Keymap::built_in()); + let rejected = FileObservation::Rejected("bad config".to_string()); + assert!(matches!( + runtime.apply_observation(rejected.clone()), + ReloadOutcome::Rejected(_), + )); + assert!(matches!( + runtime.apply_observation(rejected), + ReloadOutcome::Unchanged, + )); + assert!(runtime.last_observation_rejected()); + + assert_eq!( + runtime.apply_observation(FileObservation::Missing), + ReloadOutcome::Unchanged, + ); + assert!(!runtime.last_observation_rejected()); + } + + #[test] + fn repeated_invalid_json_remains_rejected_until_an_accepted_observation() { + let mut runtime = KeymapRuntime::new(crate::keybindings::config::Keymap::built_in()); + let invalid = FileObservation::Bytes(br#"{"idle.submit":"bad"}"#.to_vec()); + assert!(matches!( + runtime.apply_observation(invalid.clone()), + ReloadOutcome::Rejected(_), + )); + assert!(matches!( + runtime.apply_observation(invalid), + ReloadOutcome::Unchanged, + )); + assert!(runtime.last_observation_rejected()); + + assert_eq!( + runtime.apply_observation(FileObservation::Missing), + ReloadOutcome::Unchanged, + ); + assert!(!runtime.last_observation_rejected()); + } + + #[test] + fn staged_resolution_preserves_cancel_pending_global_and_context_order() { + let mut runtime = runtime_with( + r#"{ + "global.open-transcript-search":["ctrl+x ctrl+f"], + "idle.submit":["ctrl+x ctrl+s"] + }"#, + ); + let now = Instant::now(); + let owner = idle_owner(); + + assert_eq!( + runtime.resolve_cancel(ctrl('x')), + ShortcutResolution::NoMatch, + ); + assert_eq!( + runtime.advance_pending(owner, ctrl('x'), now), + ShortcutResolution::NoMatch, + ); + assert_eq!( + runtime.resolve_new_global(owner, ctrl('x'), now), + ShortcutResolution::Pending, + ); + assert_eq!( + runtime.advance_pending(owner, ctrl('f'), now + Duration::from_millis(1)), + ShortcutResolution::Action(ShortcutInvocation::chord(ShortcutAction::Global( + GlobalShortcut::OpenTranscriptSearch + ),)), + ); + + assert_eq!( + runtime.resolve_new_context(owner, ctrl('x'), now), + ShortcutResolution::Pending, + ); + assert_eq!( + runtime.advance_pending(owner, ctrl('s'), now + Duration::from_millis(1)), + ShortcutResolution::Action(ShortcutInvocation::chord(ShortcutAction::Idle( + IdleShortcut::Submit + ),)), + ); + + runtime.resolve_new_context(owner, ctrl('x'), now); + assert!(matches!( + runtime.resolve_cancel(ctrl('c')), + ShortcutResolution::Action(ShortcutInvocation { + action: ShortcutAction::Global(GlobalShortcut::Cancel), + .. + }) + )); + assert!(!runtime.has_pending_chord()); + } + + #[test] + fn shared_global_prefix_keeps_contextual_chord_reachable() { + let mut runtime = runtime_with( + r#"{ + "global.open-transcript-search":["ctrl+x ctrl+f"], + "idle.submit":["ctrl+x ctrl+s"] + }"#, + ); + let now = Instant::now(); + + assert_eq!( + runtime.resolve(idle_owner(), ctrl('x'), now), + ShortcutResolution::Pending, + ); + assert_eq!( + runtime.resolve(idle_owner(), ctrl('s'), now + Duration::from_millis(1),), + ShortcutResolution::Action(ShortcutInvocation::chord(ShortcutAction::Idle( + IdleShortcut::Submit, + ))), + ); + } + + #[test] + fn shared_global_prefix_does_not_carry_context_through_a_menu_owner() { + let mut runtime = runtime_with( + r#"{ + "global.open-transcript-search":["ctrl+x ctrl+f"], + "idle.submit":["ctrl+x ctrl+s"] + }"#, + ); + let now = Instant::now(); + let owner = InputOwnerFingerprint { + modal: ModalOwner::MentionMenu, + ..idle_owner() + }; + + assert_eq!( + runtime.resolve_new_global(owner, ctrl('x'), now), + ShortcutResolution::Pending, + ); + assert_eq!( + runtime.advance_pending(owner, ctrl('s'), now + Duration::from_millis(1)), + ShortcutResolution::RetryCurrentKey, + ); + } + + #[test] + fn shared_global_prefix_keeps_running_and_approval_chords_reachable() { + for (context, modal, action_id, expected) in [ + ( + ShortcutContext::Running, + ModalOwner::None, + "running.interrupt", + ShortcutAction::Running(crate::shortcuts::RunningShortcut::Interrupt), + ), + ( + ShortcutContext::Approval, + ModalOwner::Approval, + "approval.confirm", + ShortcutAction::Approval(crate::shortcuts::ApprovalShortcut::Confirm), + ), + ] { + let source = format!( + r#"{{ + "global.open-transcript-search":["ctrl+x ctrl+f"], + "{action_id}":["ctrl+x ctrl+s"] + }}"# + ); + let mut runtime = runtime_with(&source); + let owner = InputOwnerFingerprint { + context, + modal, + panel: PanelMode::Conversation, + vim_mode: None, + }; + let now = Instant::now(); + + assert_eq!( + runtime.resolve(owner, ctrl('x'), now), + ShortcutResolution::Pending, + ); + assert_eq!( + runtime.resolve(owner, ctrl('s'), now + Duration::from_millis(1)), + ShortcutResolution::Action(ShortcutInvocation::chord(expected)), + "{context:?}", + ); + } + } +} diff --git a/crates/orca-tui/src/lib.rs b/crates/orca-tui/src/lib.rs index 6faf87b4..25f06278 100644 --- a/crates/orca-tui/src/lib.rs +++ b/crates/orca-tui/src/lib.rs @@ -7,25 +7,35 @@ mod approval_mode_actions; mod background_approval; mod background_tasks; pub mod bridge; +mod capability_backend; mod channels; mod clipboard; pub mod commands; mod composer_input_actions; mod composer_textarea; +mod diagnostics; pub mod diff; +mod diff_highlight; mod display_text; +mod edit_highlight_worker; mod frame_scheduler; mod global_actions; mod hosted_runtime; mod idle_key_actions; mod idle_navigation_actions; mod idle_submit_actions; +mod input_adapter; mod input_event_actions; +mod input_runtime; mod interaction_broker; mod key_event_actions; +mod keybindings; mod mention_menu_actions; mod mention_search_manager; +mod onboarding; mod operation_controller; +mod queued_input; +mod queued_input_actions; mod running_actions; mod runtime_event_actions; mod runtime_event_projection; @@ -37,15 +47,21 @@ pub mod shortcuts; mod slash_command_actions; mod slash_menu_actions; mod status_key_actions; +mod streaming_markdown; mod submitted_turn; -mod terminal_lifecycle; +mod syntax_highlight; +mod terminal_capabilities; +mod terminal_presentation; pub mod theme; +mod transcript_search; mod transcript_view; pub mod types; pub mod ui; pub mod vim; +mod vim_command; mod workflow_notifications; mod workflow_panel_actions; +mod workspace_status; pub use app::run_tui; @@ -263,8 +279,10 @@ pub(crate) mod test_support { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } diff --git a/crates/orca-tui/src/mention_search_manager.rs b/crates/orca-tui/src/mention_search_manager.rs index 0de3601f..10b01469 100644 --- a/crates/orca-tui/src/mention_search_manager.rs +++ b/crates/orca-tui/src/mention_search_manager.rs @@ -9,7 +9,7 @@ use orca_file_search::{ use orca_runtime::mentions::{self, MentionToken}; use orca_runtime::mentions::{MentionCandidate, MentionCatalog}; -use crate::types::{AppState, AppStatus, TuiEvent}; +use crate::types::{AppState, AppStatus, PanelMode, TuiEvent}; const WARM_IDLE: Duration = Duration::from_secs(30); const CATALOG_RESULT_CAPACITY: usize = 8; @@ -55,7 +55,11 @@ pub(crate) struct MentionSearchManager { impl MentionSearchManager { pub(crate) fn is_enabled(state: &AppState) -> bool { - matches!(state.status, AppStatus::Idle | AppStatus::WaitingUserInput) + state.panel_mode == PanelMode::Conversation + && matches!( + state.status, + AppStatus::Idle | AppStatus::Running | AppStatus::WaitingUserInput + ) && state.slash_menu.is_none() } @@ -497,7 +501,7 @@ mod tests { use tempfile::tempdir; use super::{MentionCandidate, MentionSearchManager, TokenIdentity}; - use crate::types::{AppState, TuiEvent}; + use crate::types::{AppState, AppStatus, PanelMode, TuiEvent}; fn state() -> AppState { let (action_tx, _action_rx) = mpsc::unbounded(); @@ -509,6 +513,34 @@ mod tests { ) } + #[test] + fn mention_search_enablement_covers_only_editable_conversation_states() { + let mut state = state(); + for (status, enabled) in [ + (AppStatus::Idle, true), + (AppStatus::Running, true), + (AppStatus::WaitingUserInput, true), + (AppStatus::Compacting, false), + (AppStatus::WaitingApproval, false), + (AppStatus::Setup, false), + (AppStatus::SessionPicker, false), + ] { + state.status = status; + state.panel_mode = PanelMode::Conversation; + state.slash_menu = None; + assert_eq!( + MentionSearchManager::is_enabled(&state), + enabled, + "{status:?}" + ); + } + state.status = AppStatus::Running; + for panel in [PanelMode::Workflows, PanelMode::Agents] { + state.panel_mode = panel; + assert!(!MentionSearchManager::is_enabled(&state), "{panel:?}"); + } + } + fn snapshot(generation: SessionGeneration, query: &str, paths: &[&str]) -> SearchSnapshot { SearchSnapshot { generation, diff --git a/crates/orca-tui/src/onboarding.rs b/crates/orca-tui/src/onboarding.rs new file mode 100644 index 00000000..a4b70d2c --- /dev/null +++ b/crates/orca-tui/src/onboarding.rs @@ -0,0 +1,1253 @@ +#![cfg_attr(not(test), allow(dead_code))] + +use orca_core::config::file::{ + UserConfigSaveError, UserPreferencePatch, UserPreferenceValidationError, +}; +use orca_core::config::{ProviderKind, ThemeName}; + +const PROVIDER_OPTIONS: [ProviderKind; 1] = [ProviderKind::DeepSeek]; +const THEME_OPTIONS: [ThemeName; 5] = [ + ThemeName::Auto, + ThemeName::Dark, + ThemeName::Light, + ThemeName::Solarized, + ThemeName::Catppuccin, +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OnboardingStep { + Welcome, + Provider, + ApiKey, + Model, + Theme, + Review, + Complete, +} + +impl OnboardingStep { + pub(crate) const ALL: [Self; 7] = [ + Self::Welcome, + Self::Provider, + Self::ApiKey, + Self::Model, + Self::Theme, + Self::Review, + Self::Complete, + ]; + + pub(crate) const fn ordinal(self) -> usize { + match self { + Self::Welcome => 1, + Self::Provider => 2, + Self::ApiKey => 3, + Self::Model => 4, + Self::Theme => 5, + Self::Review => 6, + Self::Complete => 7, + } + } + + pub(crate) const fn title(self) -> &'static str { + match self { + Self::Welcome => "Welcome", + Self::Provider => "Provider", + Self::ApiKey => "API Key", + Self::Model => "Model", + Self::Theme => "Theme", + Self::Review => "Review", + Self::Complete => "Complete", + } + } + + pub(crate) const fn instruction(self) -> &'static str { + match self { + Self::Welcome => "Set up local defaults for this device.", + Self::Provider => "Choose the production provider.", + Self::ApiKey => "Enter your DeepSeek API key.", + Self::Model => "Choose the default model.", + Self::Theme => "Choose a theme to preview.", + Self::Review => "Confirm these local defaults.", + Self::Complete => "Setup is complete.", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum SaveOutcome { + NotAttempted, + Saved, + Failed(UserConfigSaveError), +} + +impl SaveOutcome { + const fn safe_label(&self) -> &'static str { + match self { + Self::NotAttempted => "save not attempted", + Self::Saved => "saved", + Self::Failed(error) => error.safe_label(), + } + } + + const fn was_attempted(&self) -> bool { + !matches!(self, Self::NotAttempted) + } +} + +impl From> for SaveOutcome { + fn from(result: Result<(), UserConfigSaveError>) -> Self { + match result { + Ok(()) => Self::Saved, + Err(error) => Self::Failed(error), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OnboardingError { + MissingApiKey, + InvalidStep, + UnsupportedProvider, + UnsupportedModel, + SharedConfigUnavailable, +} + +impl OnboardingError { + pub(crate) const fn safe_label(self) -> &'static str { + match self { + Self::MissingApiKey => "API key is required", + Self::InvalidStep => "review is unavailable", + Self::UnsupportedProvider => "unsupported provider selection", + Self::UnsupportedModel => "unsupported model selection", + Self::SharedConfigUnavailable => "shared configuration unavailable", + } + } +} + +pub(crate) const fn production_provider_options() -> &'static [ProviderKind] { + &PROVIDER_OPTIONS +} + +pub(crate) fn model_options() -> &'static [&'static str] { + orca_core::model::allowed_models() +} + +pub(crate) const fn theme_options() -> &'static [ThemeName] { + &THEME_OPTIONS +} + +struct OnboardingDraft { + provider: ProviderKind, + model: String, + theme: ThemeName, + api_key: Option, +} + +pub(crate) struct OnboardingState { + step: OnboardingStep, + draft: OnboardingDraft, + selected: usize, + auth_outcome: SaveOutcome, + preferences_outcome: SaveOutcome, + error: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OnboardingOptionRow { + pub(crate) label: &'static str, + pub(crate) description: &'static str, + pub(crate) selected: bool, +} + +impl OnboardingState { + pub(crate) fn new(provider: ProviderKind, model: &str, theme: ThemeName) -> Self { + let provider = if production_provider_options().contains(&provider) { + provider + } else { + ProviderKind::DeepSeek + }; + let model = if model_options().contains(&model) { + model.to_string() + } else { + orca_core::model::AUTO_MODEL.to_string() + }; + Self { + step: OnboardingStep::Welcome, + draft: OnboardingDraft { + provider, + model, + theme, + api_key: None, + }, + selected: 0, + auth_outcome: SaveOutcome::NotAttempted, + preferences_outcome: SaveOutcome::NotAttempted, + error: None, + } + } + + pub(crate) const fn step(&self) -> OnboardingStep { + self.step + } + + pub(crate) const fn draft_provider(&self) -> ProviderKind { + self.draft.provider + } + + pub(crate) fn draft_model(&self) -> &str { + &self.draft.model + } + + pub(crate) const fn selected_theme(&self) -> ThemeName { + self.draft.theme + } + + pub(crate) fn set_api_key(&mut self, api_key: String) { + let api_key = api_key.trim(); + if api_key.is_empty() { + self.draft.api_key = None; + self.error = Some(OnboardingError::MissingApiKey); + } else { + self.draft.api_key = Some(api_key.to_string()); + self.error = None; + } + } + + pub(crate) fn api_key(&self) -> Option<&str> { + self.draft.api_key.as_deref() + } + + pub(crate) fn take_api_key(&mut self) -> Option { + self.draft.api_key.take() + } + + pub(crate) fn review_rows(&self) -> Vec { + vec![ + "Provider: DeepSeek".to_string(), + format!("Model: {}", self.draft.model), + format!("Theme: {}", self.draft.theme.as_str()), + format!( + "API key: {}", + if self.draft.api_key.is_some() { + "configured" + } else { + "missing" + }, + ), + ] + } + + pub(crate) fn option_rows(&self) -> Vec { + match self.step { + OnboardingStep::Provider => PROVIDER_OPTIONS + .iter() + .enumerate() + .map(|(index, _)| OnboardingOptionRow { + label: "DeepSeek", + description: "Production provider", + selected: index == self.selected, + }) + .collect(), + OnboardingStep::Model => model_options() + .iter() + .enumerate() + .map(|(index, model)| OnboardingOptionRow { + label: model, + description: match *model { + orca_core::model::AUTO_MODEL => "Recommended", + orca_core::model::FLASH_MODEL => "Faster", + orca_core::model::PRO_MODEL => "Highest quality", + _ => "Unsupported", + }, + selected: index == self.selected, + }) + .collect(), + OnboardingStep::Theme => THEME_OPTIONS + .iter() + .enumerate() + .map(|(index, theme)| OnboardingOptionRow { + label: theme.as_str(), + description: match theme { + ThemeName::Auto => "Match terminal background", + ThemeName::Dark => "Dark", + ThemeName::Light => "Light", + ThemeName::Solarized => "Solarized dark", + ThemeName::Catppuccin => "Catppuccin Mocha", + }, + selected: index == self.selected, + }) + .collect(), + _ => Vec::new(), + } + } + + pub(crate) fn completion_rows(&self) -> Vec { + fn row(name: &str, outcome: &SaveOutcome) -> String { + match outcome { + SaveOutcome::Saved => format!("{name}: {}", outcome.safe_label()), + SaveOutcome::NotAttempted | SaveOutcome::Failed(_) => { + format!("{name}: current session only — {}", outcome.safe_label(),) + } + } + } + + vec![ + row("API key", &self.auth_outcome), + row("Preferences", &self.preferences_outcome), + ] + } + + pub(crate) const fn error_label(&self) -> Option<&'static str> { + match self.error { + Some(error) => Some(error.safe_label()), + None => None, + } + } + + pub(crate) const fn auth_outcome(&self) -> &SaveOutcome { + &self.auth_outcome + } + + pub(crate) const fn preferences_outcome(&self) -> &SaveOutcome { + &self.preferences_outcome + } + + pub(crate) const fn review_error(&self) -> Option { + self.error + } + + pub(crate) fn set_error(&mut self, error: OnboardingError) { + self.error = Some(error); + } + + pub(crate) fn clear_api_key_error(&mut self) { + if self.error == Some(OnboardingError::MissingApiKey) { + self.error = None; + } + } + + pub(crate) fn validate_review(&mut self) -> Result { + if self.step != OnboardingStep::Review { + return self.fail_review_validation(OnboardingError::InvalidStep); + } + let patch = match UserPreferencePatch::new( + self.draft.provider, + self.draft.model.clone(), + self.draft.theme, + ) { + Ok(patch) => patch, + Err(UserPreferenceValidationError::UnsupportedProvider) => { + return self.fail_review_validation(OnboardingError::UnsupportedProvider); + } + Err(UserPreferenceValidationError::UnsupportedModel) => { + return self.fail_review_validation(OnboardingError::UnsupportedModel); + } + }; + if !self + .draft + .api_key + .as_deref() + .is_some_and(|api_key| !api_key.trim().is_empty()) + { + return self.fail_review_validation(OnboardingError::MissingApiKey); + } + self.error = None; + Ok(patch) + } + + fn fail_review_validation(&mut self, error: OnboardingError) -> Result { + self.error = Some(error); + Err(error) + } + + pub(crate) fn finish_review(&mut self, auth: SaveOutcome, preferences: SaveOutcome) -> bool { + if self.step != OnboardingStep::Review + || self.auth_outcome != SaveOutcome::NotAttempted + || self.preferences_outcome != SaveOutcome::NotAttempted + || !auth.was_attempted() + || !preferences.was_attempted() + { + return false; + } + self.auth_outcome = auth; + self.preferences_outcome = preferences; + self.draft.api_key = None; + self.error = None; + self.step = OnboardingStep::Complete; + true + } + + pub(crate) fn advance(&mut self) -> bool { + let next = match self.step { + OnboardingStep::Welcome => OnboardingStep::Provider, + OnboardingStep::Provider => OnboardingStep::ApiKey, + OnboardingStep::ApiKey if self.draft.api_key.is_some() => OnboardingStep::Model, + OnboardingStep::Model => OnboardingStep::Theme, + OnboardingStep::Theme => OnboardingStep::Review, + OnboardingStep::ApiKey => { + self.error = Some(OnboardingError::MissingApiKey); + return false; + } + OnboardingStep::Review | OnboardingStep::Complete => { + return false; + } + }; + self.step = next; + self.selected = self.index_for_current_value(); + self.error = None; + true + } + + pub(crate) fn move_previous(&mut self) -> Option { + self.move_by(-1) + } + + pub(crate) fn move_next(&mut self) -> Option { + self.move_by(1) + } + + fn move_by(&mut self, delta: isize) -> Option { + let len = self.option_len(); + if len == 0 { + self.error = Some(OnboardingError::UnsupportedModel); + return None; + } + self.selected = (self.selected as isize + delta).rem_euclid(len as isize) as usize; + self.error = None; + match self.step { + OnboardingStep::Provider => { + self.draft.provider = PROVIDER_OPTIONS[self.selected]; + None + } + OnboardingStep::Model => { + self.draft.model = model_options()[self.selected].to_string(); + None + } + OnboardingStep::Theme => { + self.draft.theme = THEME_OPTIONS[self.selected]; + Some(self.draft.theme) + } + _ => None, + } + } + + fn option_len(&self) -> usize { + match self.step { + OnboardingStep::Provider => PROVIDER_OPTIONS.len(), + OnboardingStep::Model => model_options().len(), + OnboardingStep::Theme => THEME_OPTIONS.len(), + _ => 0, + } + } + + fn index_for_current_value(&self) -> usize { + match self.step { + OnboardingStep::Provider => PROVIDER_OPTIONS + .iter() + .position(|value| *value == self.draft.provider) + .unwrap_or(0), + OnboardingStep::Model => model_options() + .iter() + .position(|value| *value == self.draft.model) + .unwrap_or(0), + OnboardingStep::Theme => THEME_OPTIONS + .iter() + .position(|value| *value == self.draft.theme) + .unwrap_or(0), + _ => 0, + } + } + + #[cfg(test)] + pub(crate) fn set_step_for_test(&mut self, step: OnboardingStep) { + self.step = step; + self.selected = self.index_for_current_value(); + } + + #[cfg(test)] + pub(crate) fn set_model_for_test(&mut self, model: String) { + self.draft.model = model; + self.selected = self.index_for_current_value(); + } + + #[cfg(test)] + pub(crate) fn set_outcomes_for_test(&mut self, auth: SaveOutcome, preferences: SaveOutcome) { + self.auth_outcome = auth; + self.preferences_outcome = preferences; + } +} + +#[cfg(test)] +mod tests { + use orca_core::config::file::UserConfigSaveError; + use orca_core::config::{ProviderKind, ThemeName}; + + use super::*; + + const USER_CONFIG_SAVE_ERRORS: [UserConfigSaveError; 11] = [ + UserConfigSaveError::ConfigDirectoryUnavailable, + UserConfigSaveError::UnsafeExistingPath, + UserConfigSaveError::ExistingFileTooLarge, + UserConfigSaveError::InvalidExistingContent, + UserConfigSaveError::ConcurrentModification, + UserConfigSaveError::CreateDirectoryFailed, + UserConfigSaveError::CreateTemporaryFileFailed, + UserConfigSaveError::ReadFailed, + UserConfigSaveError::WriteFailed, + UserConfigSaveError::ReplaceFailed, + UserConfigSaveError::RollbackFailed, + ]; + + fn assert_user_config_save_error_is_safe(error: UserConfigSaveError) { + match error { + UserConfigSaveError::ConfigDirectoryUnavailable + | UserConfigSaveError::UnsafeExistingPath + | UserConfigSaveError::ExistingFileTooLarge + | UserConfigSaveError::InvalidExistingContent + | UserConfigSaveError::ConcurrentModification + | UserConfigSaveError::CreateDirectoryFailed + | UserConfigSaveError::CreateTemporaryFileFailed + | UserConfigSaveError::ReadFailed + | UserConfigSaveError::WriteFailed + | UserConfigSaveError::ReplaceFailed + | UserConfigSaveError::RollbackFailed => {} + } + let label = error.safe_label(); + assert!(!label.contains('/')); + assert!(!label.contains("sk-")); + assert!(!label.chars().any(char::is_control)); + } + + fn state() -> OnboardingState { + OnboardingState::new(ProviderKind::DeepSeek, "auto", ThemeName::Auto) + } + + fn markdown_heading_level(line: &str) -> Option { + let line = line.trim_end_matches(['\r', '\n']); + let level = line.bytes().take_while(|byte| *byte == b'#').count(); + (level > 0 && level <= 6 && line.as_bytes().get(level) == Some(&b' ')).then_some(level) + } + + fn markdown_fence(line: &str) -> Option<(u8, usize, &str)> { + let line = line.trim_end_matches(['\r', '\n']); + let indent = line.bytes().take_while(|byte| *byte == b' ').count(); + if indent > 3 { + return None; + } + let content = &line[indent..]; + let marker = *content.as_bytes().first()?; + if !matches!(marker, b'`' | b'~') { + return None; + } + let length = content.bytes().take_while(|byte| *byte == marker).count(); + (length >= 3).then_some((marker, length, &content[length..])) + } + + fn markdown_section<'a>(readme: &'a str, heading: &str) -> Result<&'a str, &'static str> { + let target_level = markdown_heading_level(heading).ok_or("invalid heading")?; + let mut fence = None; + let mut offset = 0; + let mut start = None; + let mut end = None; + let mut matches = 0; + + for line in readme.split_inclusive('\n') { + if let Some((marker, length)) = fence { + if let Some((candidate, candidate_length, suffix)) = markdown_fence(line) + && candidate == marker + && candidate_length >= length + && suffix.trim().is_empty() + { + fence = None; + } + offset += line.len(); + continue; + } + + if let Some((marker, length, _)) = markdown_fence(line) { + fence = Some((marker, length)); + offset += line.len(); + continue; + } + + let line_without_ending = line.trim_end_matches(['\r', '\n']); + if line_without_ending == heading { + matches += 1; + start.get_or_insert(offset); + } else if start.is_some() + && end.is_none() + && markdown_heading_level(line).is_some_and(|level| level <= target_level) + { + end = Some(offset); + } + offset += line.len(); + } + + match matches { + 0 => Err("missing heading"), + 1 => { + let start = start.expect("matched heading start"); + Ok(&readme[start..end.unwrap_or(readme.len())]) + } + _ => Err("duplicate heading"), + } + } + + #[test] + fn onboarding_has_exact_seven_step_order() { + assert_eq!( + OnboardingStep::ALL, + [ + OnboardingStep::Welcome, + OnboardingStep::Provider, + OnboardingStep::ApiKey, + OnboardingStep::Model, + OnboardingStep::Theme, + OnboardingStep::Review, + OnboardingStep::Complete, + ], + ); + assert_eq!( + OnboardingStep::ALL.map(OnboardingStep::ordinal), + [1, 2, 3, 4, 5, 6, 7], + ); + assert_eq!( + OnboardingStep::ALL.map(OnboardingStep::title), + [ + "Welcome", "Provider", "API Key", "Model", "Theme", "Review", "Complete", + ], + ); + assert!( + OnboardingStep::ALL + .iter() + .all(|step| !step.instruction().is_empty()) + ); + } + + #[test] + fn onboarding_choices_are_closed_and_production_safe() { + assert_eq!(production_provider_options(), [ProviderKind::DeepSeek]); + assert!(!production_provider_options().contains(&ProviderKind::Mock)); + assert!(!production_provider_options().contains(&ProviderKind::DeepSeekFixture)); + assert_eq!(model_options(), orca_core::model::allowed_models()); + assert_eq!( + theme_options(), + [ + ThemeName::Auto, + ThemeName::Dark, + ThemeName::Light, + ThemeName::Solarized, + ThemeName::Catppuccin, + ], + ); + } + + #[test] + fn markdown_section_requires_one_exact_heading_outside_fences() { + let fenced_only = "# Guide\n\n```markdown\n### Setup\n```\n"; + assert_eq!( + markdown_section(fenced_only, "### Setup"), + Err("missing heading") + ); + + let duplicate = "# Guide\n\n### Setup\none\n\n### Setup\ntwo\n"; + assert_eq!( + markdown_section(duplicate, "### Setup"), + Err("duplicate heading") + ); + + let prefix = "# Guide\n\n### Setup details\nwrong\n"; + assert_eq!( + markdown_section(prefix, "### Setup"), + Err("missing heading") + ); + } + + #[test] + fn markdown_section_stops_at_same_or_higher_level_heading() { + let readme = + "# Guide\n\n### Setup\ninside\n\n#### Detail\nstill inside\n\n## Next\noutside\n"; + assert_eq!( + markdown_section(readme, "### Setup"), + Ok("### Setup\ninside\n\n#### Detail\nstill inside\n\n"), + ); + } + + #[test] + fn readmes_document_expanded_first_run_onboarding_contract() { + fn assert_required(name: &str, section: &str, required: &[&str]) { + for token in required { + assert!( + section.contains(token), + "{name} onboarding docs must contain {token:?}", + ); + } + } + + fn assert_safe_examples(name: &str, section: &str) { + for forbidden in ["Mock", "Fixture", "/Users/", "/home/", "C:\\", "sk-"] { + assert!( + !section.contains(forbidden), + "{name} onboarding docs must not contain {forbidden:?}", + ); + } + } + + let english = markdown_section( + include_str!("../../../README.md"), + "### First-run onboarding", + ) + .expect("unique English onboarding heading"); + assert_required( + "README.md", + english, + &[ + "When the TUI starts without an effective API key, first-run onboarding follows exactly seven steps", + "Welcome → Provider → API Key → Model → Theme → Review → Complete", + "DeepSeek is the only production provider", + "auto", + "deepseek-v4-flash", + "deepseek-v4-pro", + "Auto", + "Dark", + "Light", + "Solarized", + "Catppuccin", + "config.toml", + "auth.json", + "no network validation", + "current session", + "reports only sanitized error categories", + "Pressing Esc", + "before confirming Review", + "Review page is open", + "before pressing Enter", + "zero writes", + "draft-only", + ], + ); + assert_safe_examples("README.md", english); + + let chinese = + markdown_section(include_str!("../../../README.zh-CN.md"), "### 首次启动设置") + .expect("unique Chinese onboarding heading"); + assert_required( + "README.zh-CN.md", + chinese, + &[ + "当 TUI 启动时未检测到有效 API 密钥,首次启动设置固定经过七步", + "欢迎 → 服务商 → API 密钥 → 模型 → 主题 → 确认 → 完成", + "DeepSeek 是唯一的生产服务商", + "auto", + "deepseek-v4-flash", + "deepseek-v4-pro", + "Auto", + "Dark", + "Light", + "Solarized", + "Catppuccin", + "config.toml", + "auth.json", + "不进行网络验证", + "当前会话", + "按 Esc", + "确认", + "页面", + "尚未按 Enter", + "不会产生任何写入", + "仅显示不含敏感信息的错误类型", + "仅保存在草稿中", + ], + ); + assert_safe_examples("README.zh-CN.md", chinese); + } + + #[test] + fn implementation_plan_documents_post_implementation_safety_alignment() { + let plan = include_str!( + "../../../docs/superpowers/plans/2026-07-29-tui-onboarding-provider-model-theme.md" + ); + let note_start = plan + .find("> **Post-implementation") + .expect("alignment note"); + let note_end = plan[note_start..] + .find("\n\n---") + .map(|offset| note_start + offset) + .expect("alignment note boundary"); + let alignment_note = &plan[note_start..note_end]; + let step_five_start = plan + .find("- [ ] **Step 5: Implement") + .expect("Task 2 Step 5"); + let step_five_end = plan[step_five_start..] + .find("- [ ] **Step 6:") + .map(|offset| step_five_start + offset) + .expect("Task 2 Step 5 boundary"); + let task_two_step_five = &plan[step_five_start..step_five_end]; + for required in [ + "Post-implementation hardening alignment (2026-07-30)", + "production hardening landed on 2026-07-29", + "the committed production implementation and tests use", + "production-aligned", + "final committed implementation", + "original execution history remains in git", + r#"assert!(!text.contains("/Users/"));"#, + r#"assert!(!text.contains("C:\\\\Users\\\\"));"#, + ] { + assert!( + plan.contains(required), + "implementation plan must contain {required:?}" + ); + } + assert!(task_two_step_five.contains("production-aligned owner-only concurrent writer")); + assert!(task_two_step_five.contains("post-implementation hardening")); + for scoped in [alignment_note, task_two_step_five] { + for unsupported in ["reviewed", "review-driven", "final reviewed code"] { + assert!( + !scoped.contains(unsupported), + "alignment scope must not claim {unsupported:?}", + ); + } + } + for unsupported in [ + "Post-implementation hardening revision (2026-07-29)", + "Review-driven hardening revision (2026-07-30)", + "Independent security review rejected", + "after that approval", + ] { + assert!( + !plan.contains(unsupported), + "implementation plan must not claim {unsupported:?}", + ); + } + } + + #[test] + fn initial_draft_uses_effective_values_and_normalizes_hidden_values() { + let effective = OnboardingState::new( + ProviderKind::DeepSeek, + orca_core::model::PRO_MODEL, + ThemeName::Solarized, + ); + assert_eq!(effective.step(), OnboardingStep::Welcome); + assert_eq!(effective.draft_provider(), ProviderKind::DeepSeek); + assert_eq!(effective.draft_model(), orca_core::model::PRO_MODEL); + assert_eq!(effective.selected_theme(), ThemeName::Solarized); + + for provider in [ProviderKind::Mock, ProviderKind::DeepSeekFixture] { + let normalized = OnboardingState::new(provider, "unsupported", ThemeName::Light); + assert_eq!(normalized.draft_provider(), ProviderKind::DeepSeek); + assert_eq!(normalized.draft_model(), orca_core::model::AUTO_MODEL); + assert_eq!(normalized.selected_theme(), ThemeName::Light); + } + } + + #[test] + fn review_rows_never_include_api_key() { + let mut state = OnboardingState::new(ProviderKind::DeepSeek, "auto", ThemeName::Dark); + state.set_api_key("sk-do-not-render".to_string()); + + let review = state.review_rows().join("\n"); + assert_eq!( + state.review_rows(), + [ + "Provider: DeepSeek", + "Model: auto", + "Theme: dark", + "API key: configured", + ], + ); + assert!(!review.contains("sk-do-not-render")); + assert!(!format!("{:?}", state.review_rows()).contains("sk-do-not-render")); + } + + #[test] + fn option_rows_have_exact_safe_labels_and_descriptions() { + let mut state = state(); + + state.set_step_for_test(OnboardingStep::Provider); + assert_eq!( + state.option_rows(), + [OnboardingOptionRow { + label: "DeepSeek", + description: "Production provider", + selected: true, + }], + ); + + state.set_step_for_test(OnboardingStep::Model); + assert_eq!( + state.option_rows(), + [ + OnboardingOptionRow { + label: "auto", + description: "Recommended", + selected: true, + }, + OnboardingOptionRow { + label: "deepseek-v4-flash", + description: "Faster", + selected: false, + }, + OnboardingOptionRow { + label: "deepseek-v4-pro", + description: "Highest quality", + selected: false, + }, + ], + ); + + state.set_step_for_test(OnboardingStep::Theme); + assert_eq!( + state.option_rows(), + [ + OnboardingOptionRow { + label: "auto", + description: "Match terminal background", + selected: true, + }, + OnboardingOptionRow { + label: "dark", + description: "Dark", + selected: false, + }, + OnboardingOptionRow { + label: "light", + description: "Light", + selected: false, + }, + OnboardingOptionRow { + label: "solarized", + description: "Solarized dark", + selected: false, + }, + OnboardingOptionRow { + label: "catppuccin", + description: "Catppuccin Mocha", + selected: false, + }, + ], + ); + } + + #[test] + fn option_selection_wraps_and_updates_draft() { + let mut state = state(); + state.set_step_for_test(OnboardingStep::Theme); + + assert_eq!(state.selected_theme(), ThemeName::Auto); + assert_eq!(state.move_previous(), Some(ThemeName::Catppuccin)); + assert_eq!(state.move_next(), Some(ThemeName::Auto)); + assert_eq!(state.move_next(), Some(ThemeName::Dark)); + + state.set_step_for_test(OnboardingStep::Model); + assert_eq!(state.move_previous(), None); + assert_eq!(state.draft_model(), orca_core::model::PRO_MODEL); + assert_eq!(state.move_next(), None); + assert_eq!(state.draft_model(), orca_core::model::AUTO_MODEL); + + state.set_step_for_test(OnboardingStep::Provider); + assert_eq!(state.move_next(), None); + assert_eq!(state.draft_provider(), ProviderKind::DeepSeek); + } + + #[test] + fn test_helpers_realign_selection_and_set_persistence_outcomes() { + let mut state = OnboardingState::new( + ProviderKind::DeepSeek, + orca_core::model::PRO_MODEL, + ThemeName::Catppuccin, + ); + + state.set_step_for_test(OnboardingStep::Model); + assert_eq!(state.option_rows()[2].selected, true); + state.set_step_for_test(OnboardingStep::Theme); + assert_eq!(state.option_rows()[4].selected, true); + + state.set_outcomes_for_test( + SaveOutcome::Saved, + SaveOutcome::Failed(UserConfigSaveError::WriteFailed), + ); + assert_eq!(state.auth_outcome(), &SaveOutcome::Saved); + assert_eq!( + state.preferences_outcome(), + &SaveOutcome::Failed(UserConfigSaveError::WriteFailed), + ); + } + + #[test] + fn advance_follows_the_seven_step_transition_matrix() { + let mut state = state(); + assert_eq!(state.step(), OnboardingStep::Welcome); + assert!(state.advance()); + assert_eq!(state.step(), OnboardingStep::Provider); + assert!(state.advance()); + assert_eq!(state.step(), OnboardingStep::ApiKey); + assert!(!state.advance()); + assert_eq!(state.step(), OnboardingStep::ApiKey); + assert_eq!(state.review_error(), Some(OnboardingError::MissingApiKey),); + + state.set_api_key("sk-staged-only".to_string()); + assert!(state.advance()); + assert_eq!(state.step(), OnboardingStep::Model); + assert!(state.advance()); + assert_eq!(state.step(), OnboardingStep::Theme); + assert!(state.advance()); + assert_eq!(state.step(), OnboardingStep::Review); + assert!(!state.advance()); + assert_eq!(state.step(), OnboardingStep::Review); + + assert!(state.finish_review(SaveOutcome::Saved, SaveOutcome::Saved)); + assert_eq!(state.step(), OnboardingStep::Complete); + assert!(!state.advance()); + assert_eq!(state.step(), OnboardingStep::Complete); + } + + #[test] + fn whitespace_api_keys_fail_closed_and_valid_keys_are_trimmed() { + let mut state = state(); + state.set_step_for_test(OnboardingStep::ApiKey); + + state.set_api_key(" \n\t ".to_string()); + assert_eq!(state.api_key(), None); + assert_eq!(state.review_error(), Some(OnboardingError::MissingApiKey),); + assert!(!state.advance()); + assert_eq!(state.step(), OnboardingStep::ApiKey); + assert_eq!(state.review_error(), Some(OnboardingError::MissingApiKey),); + + state.set_api_key(" sk-trimmed-key \n".to_string()); + assert_eq!(state.api_key(), Some("sk-trimmed-key")); + assert_eq!(state.review_error(), None); + assert!(state.advance()); + assert_eq!(state.step(), OnboardingStep::Model); + } + + #[test] + fn finish_review_rejects_calls_before_review_without_mutation() { + let mut state = state(); + state.set_api_key("sk-stays-staged".to_string()); + + assert!(!state.finish_review(SaveOutcome::Saved, SaveOutcome::Saved)); + + assert_eq!(state.step(), OnboardingStep::Welcome); + assert_eq!(state.auth_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(state.preferences_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(state.api_key(), Some("sk-stays-staged")); + } + + #[test] + fn finish_review_rejects_not_attempted_outcomes_without_mutation() { + let mut state = state(); + state.set_api_key("sk-stays-staged".to_string()); + state.set_step_for_test(OnboardingStep::Review); + + assert!(!state.finish_review(SaveOutcome::NotAttempted, SaveOutcome::Saved)); + assert_eq!(state.step(), OnboardingStep::Review); + assert_eq!(state.auth_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(state.preferences_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(state.api_key(), Some("sk-stays-staged")); + + assert!(!state.finish_review(SaveOutcome::Saved, SaveOutcome::NotAttempted)); + assert_eq!(state.step(), OnboardingStep::Review); + assert_eq!(state.auth_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(state.preferences_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(state.api_key(), Some("sk-stays-staged")); + } + + #[test] + fn finish_review_rejects_repeated_completion_without_mutation() { + let mut state = state(); + state.set_api_key("sk-original".to_string()); + state.set_step_for_test(OnboardingStep::Review); + assert!(state.finish_review(SaveOutcome::Saved, SaveOutcome::Saved)); + state.set_api_key("sk-must-survive-rejected-repeat".to_string()); + + assert!(!state.finish_review( + SaveOutcome::Failed(UserConfigSaveError::WriteFailed), + SaveOutcome::Failed(UserConfigSaveError::ReplaceFailed), + )); + + assert_eq!(state.step(), OnboardingStep::Complete); + assert_eq!(state.auth_outcome(), &SaveOutcome::Saved); + assert_eq!(state.preferences_outcome(), &SaveOutcome::Saved); + assert_eq!(state.api_key(), Some("sk-must-survive-rejected-repeat")); + } + + #[test] + fn finish_review_rejects_already_recorded_outcomes_even_if_step_is_review() { + let mut state = state(); + state.set_api_key("sk-stays-staged".to_string()); + state.set_step_for_test(OnboardingStep::Review); + state.set_outcomes_for_test(SaveOutcome::Saved, SaveOutcome::Saved); + + assert!(!state.finish_review( + SaveOutcome::Failed(UserConfigSaveError::WriteFailed), + SaveOutcome::Failed(UserConfigSaveError::ReplaceFailed), + )); + + assert_eq!(state.step(), OnboardingStep::Review); + assert_eq!(state.auth_outcome(), &SaveOutcome::Saved); + assert_eq!(state.preferences_outcome(), &SaveOutcome::Saved); + assert_eq!(state.api_key(), Some("sk-stays-staged")); + } + + #[test] + fn movement_on_non_option_steps_fails_closed() { + let mut state = state(); + for step in [ + OnboardingStep::Welcome, + OnboardingStep::ApiKey, + OnboardingStep::Review, + OnboardingStep::Complete, + ] { + state.set_step_for_test(step); + assert_eq!(state.move_previous(), None); + assert_eq!(state.move_next(), None); + assert_eq!(state.step(), step); + assert_eq!( + state.error_label(), + Some(OnboardingError::UnsupportedModel.safe_label()), + ); + } + } + + #[test] + fn review_validation_accepts_only_review_with_valid_draft_and_key() { + let mut wrong_step = state(); + wrong_step.set_api_key("sk-stays-staged".to_string()); + assert!(matches!( + wrong_step.validate_review(), + Err(OnboardingError::InvalidStep), + )); + assert_eq!(wrong_step.step(), OnboardingStep::Welcome); + assert_eq!(wrong_step.auth_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(wrong_step.preferences_outcome(), &SaveOutcome::NotAttempted,); + assert_eq!(wrong_step.api_key(), Some("sk-stays-staged")); + assert_eq!( + wrong_step.review_error(), + Some(OnboardingError::InvalidStep) + ); + + let mut unsupported_provider = state(); + unsupported_provider.set_api_key("sk-stays-staged".to_string()); + unsupported_provider.set_step_for_test(OnboardingStep::Review); + unsupported_provider.draft.provider = ProviderKind::Mock; + assert!(matches!( + unsupported_provider.validate_review(), + Err(OnboardingError::UnsupportedProvider), + )); + assert_eq!(unsupported_provider.step(), OnboardingStep::Review); + assert_eq!( + unsupported_provider.auth_outcome(), + &SaveOutcome::NotAttempted, + ); + assert_eq!( + unsupported_provider.preferences_outcome(), + &SaveOutcome::NotAttempted, + ); + assert_eq!(unsupported_provider.api_key(), Some("sk-stays-staged")); + + let mut unsupported_model = state(); + unsupported_model.set_api_key("sk-stays-staged".to_string()); + unsupported_model.set_step_for_test(OnboardingStep::Review); + unsupported_model.draft.model = "invalid".to_string(); + assert!(matches!( + unsupported_model.validate_review(), + Err(OnboardingError::UnsupportedModel), + )); + assert_eq!(unsupported_model.step(), OnboardingStep::Review); + assert_eq!(unsupported_model.api_key(), Some("sk-stays-staged")); + + let mut missing_key = state(); + missing_key.set_step_for_test(OnboardingStep::Review); + missing_key.draft.api_key = Some(" \t ".to_string()); + assert!(matches!( + missing_key.validate_review(), + Err(OnboardingError::MissingApiKey), + )); + assert_eq!(missing_key.step(), OnboardingStep::Review); + assert_eq!(missing_key.api_key(), Some(" \t ")); + assert_eq!(missing_key.auth_outcome(), &SaveOutcome::NotAttempted); + assert_eq!( + missing_key.preferences_outcome(), + &SaveOutcome::NotAttempted, + ); + + let mut valid = state(); + valid.set_api_key("sk-valid".to_string()); + valid.set_step_for_test(OnboardingStep::Review); + assert!(valid.validate_review().is_ok()); + assert_eq!(valid.review_error(), None); + assert_eq!(valid.step(), OnboardingStep::Review); + assert_eq!(valid.api_key(), Some("sk-valid")); + assert_eq!(valid.auth_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(valid.preferences_outcome(), &SaveOutcome::NotAttempted); + } + + #[test] + fn persistence_starts_not_attempted_and_errors_are_safe() { + let state = state(); + assert_eq!(state.auth_outcome(), &SaveOutcome::NotAttempted); + assert_eq!(state.preferences_outcome(), &SaveOutcome::NotAttempted); + + for error in USER_CONFIG_SAVE_ERRORS { + assert_user_config_save_error_is_safe(error); + } + + assert_eq!( + [ + OnboardingError::MissingApiKey.safe_label(), + OnboardingError::InvalidStep.safe_label(), + OnboardingError::UnsupportedProvider.safe_label(), + OnboardingError::UnsupportedModel.safe_label(), + OnboardingError::SharedConfigUnavailable.safe_label(), + ], + [ + "API key is required", + "review is unavailable", + "unsupported provider selection", + "unsupported model selection", + "shared configuration unavailable", + ], + ); + assert_eq!(SaveOutcome::NotAttempted.safe_label(), "save not attempted"); + assert_eq!(SaveOutcome::Saved.safe_label(), "saved"); + for error in USER_CONFIG_SAVE_ERRORS { + assert_eq!(SaveOutcome::Failed(error).safe_label(), error.safe_label()); + } + } + + #[test] + fn completion_rows_use_only_safe_labels() { + for error in USER_CONFIG_SAVE_ERRORS { + let mut state = state(); + state.set_outcomes_for_test(SaveOutcome::Failed(error), SaveOutcome::Failed(error)); + let rows = state.completion_rows(); + assert!(rows.iter().all(|row| row.ends_with(error.safe_label()))); + assert!(rows.iter().all(|row| !row.contains('/'))); + assert!(rows.iter().all(|row| !row.contains("sk-"))); + } + } + + #[test] + fn finish_clears_secret_and_records_independent_outcomes() { + let mut state = state(); + state.set_api_key("sk-clear-after-review".to_string()); + state.set_step_for_test(OnboardingStep::Review); + state.set_error(OnboardingError::MissingApiKey); + + assert!(state.finish_review( + SaveOutcome::Saved, + SaveOutcome::Failed(UserConfigSaveError::ConcurrentModification), + )); + + assert_eq!(state.step(), OnboardingStep::Complete); + assert_eq!(state.api_key(), None); + assert_eq!(state.take_api_key(), None); + assert_eq!(state.auth_outcome(), &SaveOutcome::Saved); + assert_eq!( + state.preferences_outcome(), + &SaveOutcome::Failed(UserConfigSaveError::ConcurrentModification), + ); + assert_eq!(state.review_error(), None); + } +} diff --git a/crates/orca-tui/src/queued_input.rs b/crates/orca-tui/src/queued_input.rs new file mode 100644 index 00000000..cfb86180 --- /dev/null +++ b/crates/orca-tui/src/queued_input.rs @@ -0,0 +1,340 @@ +use orca_runtime::mentions::MentionBindings; +use std::collections::VecDeque; + +use crate::composer_textarea::{expand_pending_pastes_with_bindings, retain_active_pending_pastes}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct QueuedUserMessage { + id: u64, + visible_text: String, + submission_text: String, + composer_bindings: MentionBindings, + submission_bindings: MentionBindings, + pending_pastes: Vec<(String, String)>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct QueuedComposerState { + pub(crate) visible_text: String, + pub(crate) mention_bindings: MentionBindings, + pub(crate) pending_pastes: Vec<(String, String)>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct QueuedPreviewSnapshot { + pub(crate) len: usize, + pub(crate) first: String, + pub(crate) second: Option, + pub(crate) latest: Option, +} + +impl QueuedPreviewSnapshot { + pub(crate) fn from_queue(queue: &VecDeque) -> Option { + Self::from_queue_with(queue, || {}) + } + + fn from_queue_with( + queue: &VecDeque, + mut on_read: impl FnMut(), + ) -> Option { + let len = queue.len(); + let first = queue.front().map(|message| { + on_read(); + message.preview_text() + })?; + let (second, latest) = if len == 2 { + let second = queue.get(1).map(|message| { + on_read(); + message.preview_text() + }); + (second, None) + } else if len > 2 { + let latest = queue.back().map(|message| { + on_read(); + message.preview_text() + }); + (None, latest) + } else { + (None, None) + }; + Some(Self { + len, + first, + second, + latest, + }) + } + + #[cfg(test)] + fn from_queue_with_probe( + queue: &VecDeque, + on_read: impl FnMut(), + ) -> Option { + Self::from_queue_with(queue, on_read) + } +} + +impl QueuedUserMessage { + pub(crate) fn from_composer( + visible_text: String, + pending_pastes: Vec<(String, String)>, + mut mention_bindings: MentionBindings, + ) -> Option { + mention_bindings.reconcile(&visible_text); + + let trimmed_visible = visible_text.trim().to_string(); + if trimmed_visible.is_empty() { + return None; + } + + let mut composer_bindings = mention_bindings.clone(); + composer_bindings.reconcile(&trimmed_visible); + + let mut submission_bindings = mention_bindings; + let expanded = expand_pending_pastes_with_bindings( + &visible_text, + &pending_pastes, + &mut submission_bindings, + ); + let submission_text = expanded.trim().to_string(); + submission_bindings.reconcile(&submission_text); + + let mut pending_pastes = pending_pastes; + retain_active_pending_pastes(&trimmed_visible, &mut pending_pastes); + + Some(Self { + id: 0, + visible_text: trimmed_visible, + submission_text, + composer_bindings, + submission_bindings, + pending_pastes, + }) + } + + pub(crate) fn visible_text(&self) -> &str { + &self.visible_text + } + + pub(crate) fn id(&self) -> u64 { + self.id + } + + pub(crate) fn assign_id(&mut self, id: u64) { + self.id = id; + } + + pub(crate) fn submission_text(&self) -> &str { + &self.submission_text + } + + #[cfg(test)] + pub(crate) fn composer_bindings(&self) -> &MentionBindings { + &self.composer_bindings + } + + pub(crate) fn submission_bindings(&self) -> &MentionBindings { + &self.submission_bindings + } + + pub(crate) fn preview_text(&self) -> String { + self.visible_text + .split_whitespace() + .collect::>() + .join(" ") + } + + pub(crate) fn into_composer_state(self) -> QueuedComposerState { + QueuedComposerState { + visible_text: self.visible_text, + mention_bindings: self.composer_bindings, + pending_pastes: self.pending_pastes, + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::collections::VecDeque; + use std::path::PathBuf; + + use orca_runtime::mentions::{MentionBinding, MentionBindings, MentionFileKind, MentionTarget}; + + use super::*; + + fn binding(text: &str, visible: &str) -> MentionBindings { + let start = text.find(visible).expect("visible mention"); + MentionBindings::from_bindings( + text, + vec![MentionBinding { + start, + end: start + visible.len(), + visible: visible.to_string(), + target: MentionTarget::File { + root: PathBuf::from("/workspace"), + path: visible.trim_start_matches('@').to_string(), + kind: MentionFileKind::File, + }, + }], + ) + } + + #[test] + fn queued_message_rejects_blank_input_and_preserves_atomic_composer_state() { + assert!( + QueuedUserMessage::from_composer( + " \n ".to_string(), + Vec::new(), + MentionBindings::default(), + ) + .is_none() + ); + + let visible = "review @item.rs [Pasted Content 1001 chars]"; + let pasted = "body\n".repeat(201); + let message = QueuedUserMessage::from_composer( + visible.to_string(), + vec![("[Pasted Content 1001 chars]".to_string(), pasted.clone())], + binding(visible, "@item.rs"), + ) + .expect("queued message"); + + assert_eq!(message.visible_text(), visible); + assert_eq!( + message.submission_text(), + format!("review @item.rs {}", pasted.trim()) + ); + assert_eq!(message.composer_bindings().bindings().len(), 1); + assert_eq!(message.submission_bindings().bindings().len(), 1); + + let restored = message.into_composer_state(); + assert_eq!(restored.visible_text, visible); + assert_eq!(restored.pending_pastes.len(), 1); + assert_eq!(restored.mention_bindings.bindings().len(), 1); + } + + #[test] + fn queued_preview_collapses_whitespace_and_never_expands_large_paste() { + let visible = "alpha\n beta [Pasted Content 1001 chars]"; + let message = QueuedUserMessage::from_composer( + visible.to_string(), + vec![( + "[Pasted Content 1001 chars]".to_string(), + "secret payload\n".repeat(100), + )], + MentionBindings::default(), + ) + .unwrap(); + + assert_eq!( + message.preview_text(), + "alpha beta [Pasted Content 1001 chars]" + ); + assert!(!message.preview_text().contains("secret payload")); + } + + #[test] + fn queued_preview_snapshot_reads_at_most_head_and_tail() { + let queue = (0..64) + .map(|index| { + QueuedUserMessage::from_composer( + format!("item {index}"), + Vec::new(), + MentionBindings::default(), + ) + .unwrap() + }) + .collect::>(); + let reads = Cell::new(0); + let snapshot = QueuedPreviewSnapshot::from_queue_with_probe(&queue, || { + reads.set(reads.get() + 1); + }) + .unwrap(); + assert_eq!(snapshot.len, 64); + assert_eq!(snapshot.first, "item 0"); + assert_eq!(snapshot.second, None); + assert_eq!(snapshot.latest.as_deref(), Some("item 63")); + assert!(reads.get() <= 2); + } + + #[test] + fn queued_preview_snapshot_reads_both_items_for_length_two() { + let queue = ["first", "second"] + .into_iter() + .map(|text| { + QueuedUserMessage::from_composer( + text.to_string(), + Vec::new(), + MentionBindings::default(), + ) + .unwrap() + }) + .collect::>(); + let snapshot = QueuedPreviewSnapshot::from_queue(&queue).unwrap(); + assert_eq!(snapshot.first, "first"); + assert_eq!(snapshot.second.as_deref(), Some("second")); + assert_eq!(snapshot.latest, None); + } + + #[test] + fn queued_message_retains_only_exact_active_overlapping_paste_placeholder() { + let base = "[Pasted Content 1001 chars]"; + let suffixed = "[Pasted Content 1001 chars] #2"; + let message = QueuedUserMessage::from_composer( + suffixed.to_string(), + vec![ + (base.to_string(), "base payload".to_string()), + (suffixed.to_string(), "second payload".to_string()), + ], + MentionBindings::default(), + ) + .unwrap(); + + let restored = message.into_composer_state(); + assert_eq!( + restored.pending_pastes, + vec![(suffixed.to_string(), "second payload".to_string())] + ); + } + + #[test] + fn queued_message_preserves_mention_between_multiple_paste_replacements() { + let first = "[Pasted Content 1001 chars]"; + let second = "[Pasted Content 1001 chars] #2"; + let visible = format!("{first} review @item.rs {second}"); + let mention_start = visible.find("@item.rs").unwrap(); + let message = QueuedUserMessage::from_composer( + visible.clone(), + vec![ + (first.to_string(), "first payload".to_string()), + (second.to_string(), "second payload".to_string()), + ], + MentionBindings::from_bindings( + &visible, + vec![MentionBinding { + start: mention_start, + end: mention_start + "@item.rs".len(), + visible: "@item.rs".to_string(), + target: MentionTarget::File { + root: PathBuf::from("/workspace"), + path: "item.rs".to_string(), + kind: MentionFileKind::File, + }, + }], + ), + ) + .unwrap(); + + assert_eq!(message.submission_bindings().bindings().len(), 1); + assert_eq!( + message.submission_bindings().bindings()[0].visible, + "@item.rs" + ); + assert_eq!( + &message.submission_text()[message.submission_bindings().bindings()[0].start + ..message.submission_bindings().bindings()[0].end], + "@item.rs" + ); + } +} diff --git a/crates/orca-tui/src/queued_input_actions.rs b/crates/orca-tui/src/queued_input_actions.rs new file mode 100644 index 00000000..aa2f4d3e --- /dev/null +++ b/crates/orca-tui/src/queued_input_actions.rs @@ -0,0 +1,521 @@ +use crossbeam_channel as mpsc; +use crossterm::event::{Event, KeyCode, KeyEvent}; +use orca_core::config::RunConfig; +use tui_textarea::TextArea; + +use crate::composer_input_actions::{apply_composer_key_input, insert_composer_newline}; +use crate::composer_textarea::{make_textarea, make_textarea_with_text, textarea_text}; +use crate::keybindings::{ + InputOwnerFingerprint, KeymapRuntime, ShortcutInvocation, ShortcutResolution, +}; +use crate::mention_menu_actions::handle_mention_menu_key; +use crate::operation_controller::TuiOperationInterrupt; +use crate::queued_input::QueuedUserMessage; +use crate::running_actions::handle_running_shortcut; +use crate::shortcuts::{RunningShortcut, ShortcutAction, ShortcutContext, resolve_shortcut}; +use crate::theme::Theme; +use crate::types::{AppState, AppStatus, PanelMode, UserAction}; +use crate::vim::VimState; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum QueuedDispatch { + Started, + None, + Blocked, + Failed, +} + +pub(crate) fn enqueue_composer_follow_up( + state: &mut AppState, + textarea: &mut TextArea, + vim_state: &mut VimState, + theme: &Theme, +) -> bool { + let Some(message) = QueuedUserMessage::from_composer( + textarea_text(textarea), + state.pending_pastes.clone(), + state.mention_bindings.clone(), + ) else { + return false; + }; + + if state.enqueue_user_message(message).is_err() { + state.queued_input_error = Some("queued follow-up limit reached".to_string()); + return false; + } + + state.slash_menu = None; + state.mention.clear_projection(); + state.pending_pastes.clear(); + state.mention_bindings.clear(); + state.reset_history_navigation(); + vim_state.reset_insert(textarea, theme); + *textarea = make_textarea(vim_state, theme); + true +} + +pub(crate) fn restore_latest_queued_message( + state: &mut AppState, + textarea: &mut TextArea, + vim_state: &mut VimState, + theme: &Theme, +) -> bool { + if state.panel_mode != PanelMode::Conversation + || !matches!(state.status, AppStatus::Idle | AppStatus::Running) + || state.transcript_search.open + || state.show_shortcuts + || state.slash_menu.is_some() + || state.mention.phase.is_some() + { + return false; + } + let Some(composer) = state + .pop_latest_queued_message() + .map(QueuedUserMessage::into_composer_state) + else { + return false; + }; + + vim_state.reset_insert(textarea, theme); + *textarea = make_textarea_with_text(&composer.visible_text, vim_state, theme); + state.mention_bindings = composer.mention_bindings; + state.pending_pastes = composer.pending_pastes; + state.reset_history_navigation(); + true +} + +pub(crate) fn dispatch_next_queued_user_message( + state: &mut AppState, + action_tx: &mpsc::Sender, +) -> QueuedDispatch { + if state.queued_submission_in_flight.is_some() { + return QueuedDispatch::Blocked; + } + let Some(action) = state.begin_next_queued_message() else { + return QueuedDispatch::None; + }; + + match action_tx.try_send(action) { + Ok(()) => { + state.commit_queued_submission_admission(); + QueuedDispatch::Started + } + Err(mpsc::TrySendError::Full(_)) => { + state.rollback_queued_submission(); + state.queued_input_error = Some("follow-up action queue is full".to_string()); + QueuedDispatch::Failed + } + Err(mpsc::TrySendError::Disconnected(_)) => { + state.rollback_queued_submission(); + state.queued_input_error = Some("follow-up action channel is closed".to_string()); + QueuedDispatch::Failed + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_running_key( + ev: &Event, + key: &KeyEvent, + state: &mut AppState, + config: &RunConfig, + action_tx: &mpsc::Sender, + operation: &impl TuiOperationInterrupt, + textarea: &mut TextArea, + vim_state: &mut VimState, + theme: &Theme, +) -> bool { + if state.show_shortcuts { + return true; + } + if state.panel_mode == PanelMode::Conversation + && (!state.mention.candidates.is_empty() + || (state.mention.phase.is_some() && key.code == KeyCode::Esc)) + && handle_mention_menu_key(ev, key, state, textarea, vim_state, theme) + { + vim_state.cancel_pending_command(); + return true; + } + + if let Some(ShortcutAction::Running(shortcut)) = + resolve_shortcut(ShortcutContext::Running, *key) + { + return handle_running_shortcut_invocation( + ShortcutInvocation::key(ShortcutAction::Running(shortcut), *key), + state, + config, + action_tx, + operation, + textarea, + vim_state, + theme, + ); + } + + if state.panel_mode == PanelMode::Conversation { + return apply_composer_key_input(ev, key, state, config, textarea, vim_state, theme); + } + false +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_running_key_dynamic( + ev: &Event, + key: &KeyEvent, + now: std::time::Instant, + owner: InputOwnerFingerprint, + keymap: &mut KeymapRuntime, + state: &mut AppState, + config: &RunConfig, + action_tx: &mpsc::Sender, + operation: &impl TuiOperationInterrupt, + textarea: &mut TextArea, + vim_state: &mut VimState, + theme: &Theme, +) -> bool { + if state.show_shortcuts { + return true; + } + if state.panel_mode == PanelMode::Conversation + && (!state.mention.candidates.is_empty() + || (state.mention.phase.is_some() && key.code == KeyCode::Esc)) + && handle_mention_menu_key(ev, key, state, textarea, vim_state, theme) + { + vim_state.cancel_pending_command(); + return true; + } + match keymap.resolve_new_context(owner, *key, now) { + ShortcutResolution::Action(invocation) => handle_running_shortcut_invocation( + invocation, state, config, action_tx, operation, textarea, vim_state, theme, + ), + ShortcutResolution::Pending => true, + ShortcutResolution::RetryCurrentKey | ShortcutResolution::NoMatch => { + if state.panel_mode == PanelMode::Conversation { + apply_composer_key_input(ev, key, state, config, textarea, vim_state, theme) + } else { + false + } + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_running_shortcut_invocation( + invocation: ShortcutInvocation, + state: &mut AppState, + _config: &RunConfig, + action_tx: &mpsc::Sender, + operation: &impl TuiOperationInterrupt, + textarea: &mut TextArea, + vim_state: &mut VimState, + theme: &Theme, +) -> bool { + let ShortcutAction::Running(shortcut) = invocation.action else { + return false; + }; + vim_state.cancel_pending_command(); + match shortcut { + RunningShortcut::SubmitQueued => { + if state.panel_mode != PanelMode::Conversation { + return false; + } + enqueue_composer_follow_up(state, textarea, vim_state, theme); + } + RunningShortcut::Newline => { + if state.panel_mode != PanelMode::Conversation { + return false; + } + insert_composer_newline(textarea, state); + } + RunningShortcut::EditLatestQueued => { + if state.panel_mode != PanelMode::Conversation { + return false; + } + restore_latest_queued_message(state, textarea, vim_state, theme); + } + shortcut => handle_running_shortcut(shortcut, state, action_tx, operation), + } + true +} + +#[cfg(test)] +mod tests { + use orca_core::config::ThemeName; + use orca_runtime::mentions::MentionBindings; + + use super::*; + use crate::composer_textarea::{make_textarea_with_text, textarea_text}; + use crate::queued_input::QueuedUserMessage; + use crate::types::{AppState, AppStatus, ChatMessage}; + use crate::vim::VimState; + + fn state() -> AppState { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + state + } + + fn queued(text: &str) -> QueuedUserMessage { + QueuedUserMessage::from_composer(text.to_string(), Vec::new(), MentionBindings::default()) + .unwrap() + } + + fn theme() -> Theme { + Theme::named(ThemeName::Dark) + } + + #[test] + fn enqueue_from_composer_clears_only_after_acceptance() { + let mut state = state(); + let theme = theme(); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("follow up", &vim, &theme); + + assert!(enqueue_composer_follow_up( + &mut state, + &mut textarea, + &mut vim, + &theme, + )); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(textarea_text(&textarea), ""); + assert_eq!(state.status, AppStatus::Running); + assert!(state.pending_pastes.is_empty()); + assert!(state.mention_bindings.is_empty()); + assert!( + !state + .messages + .iter() + .any(|message| matches!(message, ChatMessage::User(_))) + ); + } + + #[test] + fn full_queue_keeps_composer_and_emits_no_transcript_error() { + let mut state = state(); + for index in 0..crate::channels::USER_ACTION_CAPACITY { + state + .enqueue_user_message(queued(&format!("{index}"))) + .unwrap(); + } + let theme = theme(); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("keep me", &vim, &theme); + + assert!(!enqueue_composer_follow_up( + &mut state, + &mut textarea, + &mut vim, + &theme, + )); + assert_eq!(textarea_text(&textarea), "keep me"); + assert!(state.queued_input_error.is_some()); + assert!( + !state + .messages + .iter() + .any(|message| matches!(message, ChatMessage::Error(_))) + ); + } + + #[test] + fn restore_latest_replaces_draft_and_preserves_earlier_fifo_items() { + let mut state = state(); + state.enqueue_user_message(queued("first")).unwrap(); + state.enqueue_user_message(queued("latest")).unwrap(); + let theme = theme(); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("draft", &vim, &theme); + + assert!(restore_latest_queued_message( + &mut state, + &mut textarea, + &mut vim, + &theme, + )); + assert_eq!(textarea_text(&textarea), "latest"); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!( + state.queued_user_messages.front().unwrap().visible_text(), + "first" + ); + assert!(state.queued_input_error.is_none()); + } + + #[test] + fn queued_dispatch_sends_one_fifo_item_nonblocking() { + let (action_tx, action_rx) = mpsc::bounded(1); + let mut state = state(); + state.enqueue_user_message(queued("first")).unwrap(); + state.enqueue_user_message(queued("second")).unwrap(); + state.set_status(AppStatus::Idle); + + assert_eq!( + dispatch_next_queued_user_message(&mut state, &action_tx), + QueuedDispatch::Started + ); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitQueued { prompt, .. }) if prompt == "first" + )); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(state.queued_user_messages[0].visible_text(), "second"); + assert!(state.queued_submission_in_flight.is_some()); + assert_eq!( + state.input_history.last().map(String::as_str), + Some("first") + ); + } + + #[test] + fn full_and_disconnected_action_channels_restore_queue_front() { + for disconnected in [false, true] { + let (action_tx, action_rx) = mpsc::bounded(1); + if disconnected { + drop(action_rx); + } else { + action_tx + .send(UserAction::Remember("occupy".to_string())) + .unwrap(); + } + let mut state = state(); + state.enqueue_user_message(queued("first")).unwrap(); + state.set_status(AppStatus::Idle); + + assert_eq!( + dispatch_next_queued_user_message(&mut state, &action_tx), + QueuedDispatch::Failed, + "disconnected={disconnected}" + ); + assert_eq!(state.status, AppStatus::Idle); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(state.queued_user_messages[0].visible_text(), "first"); + assert!(state.queued_submission_in_flight.is_none()); + assert!( + !state + .messages + .iter() + .any(|message| matches!(message, ChatMessage::User(text) if text == "first")) + ); + assert!(!state.input_history.iter().any(|entry| entry == "first")); + assert!(state.queued_input_error.is_some()); + } + } + + #[test] + fn non_conversation_running_enter_never_queues_composer_text() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = state(); + state.panel_mode = PanelMode::Workflows; + let config = crate::test_support::test_run_config(); + let operation = crate::test_support::TestOperationInterrupt::default(); + let theme = theme(); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("hidden draft", &vim, &theme); + let key = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE); + + assert!(!handle_running_key( + &Event::Key(key), + &key, + &mut state, + &config, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + )); + assert!(state.queued_user_messages.is_empty()); + assert_eq!(textarea_text(&textarea), "hidden draft"); + assert!(action_rx.try_recv().is_err()); + } + + #[test] + fn shortcuts_overlay_blocks_running_composer_edit_and_submit() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = state(); + state.show_shortcuts = true; + let config = crate::test_support::test_run_config(); + let operation = crate::test_support::TestOperationInterrupt::default(); + let theme = theme(); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("draft", &vim, &theme); + + for key in [ + KeyEvent::new(KeyCode::Char('x'), crossterm::event::KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE), + ] { + assert!(handle_running_key( + &Event::Key(key), + &key, + &mut state, + &config, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + )); + } + + assert_eq!(textarea_text(&textarea), "draft"); + assert!(state.queued_user_messages.is_empty()); + assert!(action_rx.try_recv().is_err()); + } + + #[test] + fn running_chord_newline_executes_semantics_without_inserting_tail_key() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = state(); + let config = crate::test_support::test_run_config(); + let operation = crate::test_support::TestOperationInterrupt::default(); + let theme = theme(); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("draft", &vim, &theme); + + assert!(handle_running_shortcut_invocation( + ShortcutInvocation::chord(ShortcutAction::Running(RunningShortcut::Newline)), + &mut state, + &config, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + )); + + assert_eq!(textarea_text(&textarea), "draft\n"); + } + + #[test] + fn running_chord_composer_action_is_rejected_outside_conversation() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = state(); + state.panel_mode = PanelMode::Workflows; + let config = crate::test_support::test_run_config(); + let operation = crate::test_support::TestOperationInterrupt::default(); + let theme = theme(); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("draft", &vim, &theme); + + assert!(!handle_running_shortcut_invocation( + ShortcutInvocation::chord(ShortcutAction::Running(RunningShortcut::SubmitQueued,)), + &mut state, + &config, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + )); + + assert_eq!(textarea_text(&textarea), "draft"); + assert!(state.queued_user_messages.is_empty()); + } +} diff --git a/crates/orca-tui/src/running_actions.rs b/crates/orca-tui/src/running_actions.rs index a1c5a2f6..442ce9a1 100644 --- a/crates/orca-tui/src/running_actions.rs +++ b/crates/orca-tui/src/running_actions.rs @@ -1,6 +1,7 @@ use crossbeam_channel as mpsc; use crate::operation_controller::TuiOperationInterrupt; +use crate::queued_input_actions::dispatch_next_queued_user_message; use crate::shortcuts::RunningShortcut; use crate::types::{AppState, AppStatus, UserAction}; @@ -14,8 +15,11 @@ pub(crate) fn handle_running_shortcut( RunningShortcut::BackgroundCurrentTurn => { let _ = action_tx.send(UserAction::BackgroundCurrentTurn); state.set_status(AppStatus::Idle); + state.resume_queued_follow_up_autosend(); + dispatch_next_queued_user_message(state, action_tx); } RunningShortcut::Interrupt => { + state.suspend_queued_follow_up_autosend(); operation.interrupt_current(); let _ = action_tx.send(UserAction::Interrupt); } @@ -41,5 +45,76 @@ pub(crate) fn handle_running_shortcut( let page = state.visible_height / 2; state.scroll_down(page); } + RunningShortcut::SubmitQueued + | RunningShortcut::Newline + | RunningShortcut::EditLatestQueued => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::queued_input::QueuedUserMessage; + use crate::test_support::TestOperationInterrupt; + use orca_runtime::mentions::MentionBindings; + + fn state(action_tx: mpsc::Sender) -> AppState { + AppState::new( + action_tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ) + } + + fn queued(text: &str) -> QueuedUserMessage { + QueuedUserMessage::from_composer(text.to_string(), Vec::new(), MentionBindings::default()) + .unwrap() + } + + #[test] + fn running_interrupt_suspends_queued_autosend() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = state(action_tx.clone()); + state.enter_running(); + let operation = TestOperationInterrupt::default(); + + handle_running_shortcut( + RunningShortcut::Interrupt, + &mut state, + &action_tx, + &operation, + ); + + assert!(!state.queued_follow_up_autosend); + assert_eq!(operation.call_count(), 1); + assert!(matches!(action_rx.try_recv(), Ok(UserAction::Interrupt))); + } + + #[test] + fn background_control_precedes_one_queued_submit() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = state(action_tx.clone()); + state.enter_running(); + state.enqueue_user_message(queued("follow up")).unwrap(); + let operation = TestOperationInterrupt::default(); + + handle_running_shortcut( + RunningShortcut::BackgroundCurrentTurn, + &mut state, + &action_tx, + &operation, + ); + + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::BackgroundCurrentTurn) + )); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitQueued { prompt, .. }) if prompt == "follow up" + )); + assert!(state.queued_submission_in_flight.is_some()); + assert_eq!(operation.call_count(), 0); } } diff --git a/crates/orca-tui/src/runtime_event_actions.rs b/crates/orca-tui/src/runtime_event_actions.rs index 2ba4e054..421bc5d7 100644 --- a/crates/orca-tui/src/runtime_event_actions.rs +++ b/crates/orca-tui/src/runtime_event_actions.rs @@ -4,6 +4,8 @@ use tui_textarea::TextArea; use crate::bridge; use crate::composer_textarea::make_textarea_with_text; +use crate::queued_input_actions::{QueuedDispatch, dispatch_next_queued_user_message}; +use crate::terminal_presentation::{TerminalNotification, TerminalPresentation}; use crate::theme::Theme; use crate::types::{AppState, AppStatus, TuiEvent, UserAction}; use crate::vim::VimState; @@ -13,6 +15,35 @@ use crate::workflow_notifications::{ submit_pending_workflow_notification, }; +pub(crate) fn terminal_notification_for_event( + event: &TuiEvent, + state: &AppState, +) -> Option { + let message = match event { + TuiEvent::ApprovalNeeded { tool, target, .. } + if !state.approval_is_allowlisted(tool, target.as_deref()) => + { + "Approval required" + } + TuiEvent::ApprovalNeeded { .. } => return None, + TuiEvent::PermissionApprovalNeeded { .. } => "Permission approval required", + TuiEvent::UserInputRequested { .. } => "Input required", + TuiEvent::McpElicitationRequested { .. } => "MCP input required", + TuiEvent::SessionCompleted { status } if status == "success" => "Task completed", + TuiEvent::SessionCompleted { status } => { + return Some(TerminalNotification::new(format!("Task {status}"))); + } + TuiEvent::WorkflowNotification { status, .. } if status == "completed" => { + "Workflow completed" + } + TuiEvent::WorkflowNotification { status, .. } => { + return Some(TerminalNotification::new(format!("Workflow {status}"))); + } + _ => return None, + }; + Some(TerminalNotification::new(message)) +} + pub(crate) fn handle_runtime_event( tui_event: TuiEvent, state: &mut AppState, @@ -21,12 +52,17 @@ pub(crate) fn handle_runtime_event( textarea: &mut TextArea, vim_state: &mut VimState, theme: &Theme, + presentation: &mut TerminalPresentation, ) { + let initial_status = state.status; + let terminal_notification = terminal_notification_for_event(&tui_event, state); if let TuiEvent::ApprovalNeeded { key, tool, target, .. } = &tui_event && state.approval_is_allowlisted(tool, target.as_deref()) { + vim_state.flush_pending_insert_escape(textarea); + vim_state.cancel_pending_command(); let _ = action_tx.send(UserAction::RespondToInteraction { key: key.clone(), response: crate::types::TuiInteractionResponse::Approval(true), @@ -35,8 +71,16 @@ pub(crate) fn handle_runtime_event( return; } + let queued_submission_rejected = match &tui_event { + TuiEvent::SubmissionRejected { + queued_id: Some(id), + .. + } => state.queued_submission_matches_id(*id), + _ => false, + }; let restored_prompt = match &tui_event { - TuiEvent::Backtracked { prompt } | TuiEvent::SubmissionRejected { prompt, .. } => { + TuiEvent::Backtracked { prompt } => Some(prompt.clone()), + TuiEvent::SubmissionRejected { prompt, .. } if !queued_submission_rejected => { Some(prompt.clone()) } _ => None, @@ -48,21 +92,47 @@ pub(crate) fn handle_runtime_event( state.status == AppStatus::Running, ); + let previous_status = state.status; state.update(tui_event); + if state.status != previous_status { + vim_state.flush_pending_insert_escape(textarea); + vim_state.cancel_pending_command(); + } + if let Some(notification) = terminal_notification { + presentation.enqueue(notification); + } if let Some(id) = batch_queued_workflow_notification_id { remove_pending_workflow_notification_by_id(state, &id); } - if let Some(prompt) = restored_prompt { + if queued_submission_rejected { + if let Some(composer) = state.take_rejected_queued_composer_state() { + vim_state.flush_pending_insert_escape(textarea); + vim_state.reset_insert(textarea, theme); + *textarea = make_textarea_with_text(&composer.visible_text, vim_state, theme); + state.mention_bindings = composer.mention_bindings; + state.pending_pastes = composer.pending_pastes; + state.reset_history_navigation(); + } + } else if let Some(prompt) = restored_prompt { + vim_state.flush_pending_insert_escape(textarea); vim_state.reset_insert(textarea, theme); *textarea = make_textarea_with_text(&prompt, vim_state, theme); } if workflow_notification_turn_boundary { drain_pending_workflow_notifications(state, pending_workflow_notifications); - submit_pending_workflow_notification(state, action_tx, false); - } else { + if dispatch_next_queued_user_message(state, action_tx) == QueuedDispatch::None + && !state.queued_follow_up_pending_or_in_flight() + { + submit_pending_workflow_notification(state, action_tx, false); + } + } else if !state.queued_follow_up_pending_or_in_flight() { submit_pending_workflow_notification(state, action_tx, true); } + if state.status != initial_status { + vim_state.flush_pending_insert_escape(textarea); + vim_state.cancel_pending_command(); + } if state.auto_scroll { state.scroll_to_bottom(); } @@ -72,7 +142,382 @@ pub(crate) fn handle_runtime_event( mod tests { use super::*; use crate::composer_textarea::textarea_text; - use orca_core::config::ThemeName; + use crate::queued_input::QueuedUserMessage; + use crate::queued_input_actions::{QueuedDispatch, dispatch_next_queued_user_message}; + use crate::terminal_presentation::TerminalNotification; + use crate::types::{ + ChatMessage, PendingWorkflowNotification, TuiInteractionKey, TuiInteractionKind, + }; + use orca_core::cancel::OperationIdAllocator; + use orca_core::config::{ThemeName, VimInsertEscapeSequence}; + use orca_runtime::mentions::{MentionBinding, MentionBindings, MentionFileKind, MentionTarget}; + use orca_runtime::runtime_pending_interaction::RuntimeMcpElicitationMode; + use std::path::PathBuf; + use std::time::Instant; + use tui_textarea::CursorMove; + + fn vim_insert_input(character: char) -> tui_textarea::Input { + tui_textarea::Input { + key: tui_textarea::Key::Char(character), + ctrl: false, + alt: false, + shift: false, + } + } + + fn interaction_key(kind: TuiInteractionKind, id: &str) -> TuiInteractionKey { + TuiInteractionKey::new(OperationIdAllocator::new().allocate(), id, kind) + } + + fn notification_message(event: &TuiEvent, state: &AppState) -> Option { + terminal_notification_for_event(event, state) + } + + fn test_presentation() -> TerminalPresentation { + TerminalPresentation::new( + false, + crate::terminal_presentation::TerminalPresentationProfile { + osc9_supported: false, + tmux_passthrough: false, + }, + ) + } + + fn queue_text(state: &mut AppState, text: &str) { + state + .enqueue_user_message( + QueuedUserMessage::from_composer( + text.to_string(), + Vec::new(), + MentionBindings::default(), + ) + .unwrap(), + ) + .unwrap(); + } + + #[test] + fn terminal_boundary_promotes_user_follow_up_before_workflow_notification() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + queue_text(&mut state, "first"); + state + .pending_workflow_notifications + .push_back(PendingWorkflowNotification { + id: "workflow-1".to_string(), + prompt: "internal workflow".to_string(), + }); + state.enter_running(); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let mut presentation = test_presentation(); + + handle_runtime_event( + TuiEvent::SessionCompleted { + status: "success".to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitQueued { prompt, .. }) if prompt == "first" + )); + assert_eq!(state.pending_workflow_notifications.len(), 1); + } + + #[test] + fn idle_workflow_notification_waits_behind_held_user_follow_up() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + queue_text(&mut state, "user first"); + state.suspend_queued_follow_up_autosend(); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let mut presentation = test_presentation(); + + handle_runtime_event( + TuiEvent::WorkflowNotification { + id: "workflow-1".to_string(), + prompt: "internal workflow".to_string(), + status: "completed".to_string(), + summary: "done".to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert!(action_rx.try_recv().is_err()); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(state.pending_workflow_notifications.len(), 1); + } + + #[test] + fn terminal_workflow_notification_waits_behind_interrupted_user_follow_up() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + queue_text(&mut state, "user first"); + state.suspend_queued_follow_up_autosend(); + state + .pending_workflow_notifications + .push_back(PendingWorkflowNotification { + id: "workflow-1".to_string(), + prompt: "internal workflow".to_string(), + }); + state.enter_running(); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let mut presentation = test_presentation(); + + handle_runtime_event( + TuiEvent::SessionCompleted { + status: "cancelled".to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert!(action_rx.try_recv().is_err()); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(state.pending_workflow_notifications.len(), 1); + } + + #[test] + fn every_terminal_status_promotes_one_follow_up() { + for status in ["success", "failed", "verification_failed", "cancelled"] { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + queue_text(&mut state, status); + state.enter_running(); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let mut presentation = test_presentation(); + + handle_runtime_event( + TuiEvent::SessionCompleted { + status: status.to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitQueued { prompt, .. }) if prompt == status + )); + } + } + + #[test] + fn occupied_admission_fence_blocks_late_terminal() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + queue_text(&mut state, "first"); + queue_text(&mut state, "second"); + assert_eq!( + dispatch_next_queued_user_message(&mut state, &action_tx), + QueuedDispatch::Started + ); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitQueued { prompt, .. }) if prompt == "first" + )); + state.set_status(AppStatus::Idle); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let mut presentation = test_presentation(); + + handle_runtime_event( + TuiEvent::SessionCompleted { + status: "backgrounded".to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert!(action_rx.try_recv().is_err()); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(state.queued_user_messages[0].visible_text(), "second"); + } + + #[test] + fn rejected_promoted_follow_up_restores_visible_paste_and_mentions() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/workspace".to_string(), + ); + let visible = "review @item.rs [Pasted Content 1001 chars]"; + let mention_start = visible.find("@item.rs").unwrap(); + let pasted = "payload\n".repeat(150); + state + .enqueue_user_message( + QueuedUserMessage::from_composer( + visible.to_string(), + vec![("[Pasted Content 1001 chars]".to_string(), pasted.clone())], + MentionBindings::from_bindings( + visible, + vec![MentionBinding { + start: mention_start, + end: mention_start + "@item.rs".len(), + visible: "@item.rs".to_string(), + target: MentionTarget::File { + root: PathBuf::from("/workspace"), + path: "item.rs".to_string(), + kind: MentionFileKind::File, + }, + }], + ), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + dispatch_next_queued_user_message(&mut state, &action_tx), + QueuedDispatch::Started + ); + let prompt = match action_rx.try_recv().unwrap() { + UserAction::SubmitQueued { prompt, .. } => prompt, + other => panic!("unexpected action: {other:?}"), + }; + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let mut presentation = test_presentation(); + + handle_runtime_event( + TuiEvent::SubmissionRejected { + queued_id: Some(state.queued_submission_in_flight.as_ref().unwrap().id()), + prompt, + message: "rejected".to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert_eq!(textarea_text(&textarea), visible); + assert_eq!(state.pending_pastes[0].1, pasted); + assert_eq!(state.mention_bindings.bindings().len(), 1); + assert!(state.queued_submission_in_flight.is_none()); + assert!( + !state + .messages + .iter() + .any(|message| matches!(message, ChatMessage::User(text) if text == visible)) + ); + } + + #[test] + fn unrelated_submission_rejection_does_not_restore_queued_fence() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + queue_text(&mut state, "queued prompt"); + assert_eq!( + dispatch_next_queued_user_message(&mut state, &action_tx), + QueuedDispatch::Started + ); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitQueued { prompt, .. }) + if prompt == "queued prompt" + )); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let mut presentation = test_presentation(); + + handle_runtime_event( + TuiEvent::SubmissionRejected { + queued_id: Some(u64::MAX), + prompt: "other prompt".to_string(), + message: "other rejection".to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert!(state.queued_submission_in_flight.is_some()); + assert_eq!(textarea_text(&textarea), "other prompt"); + } #[test] fn submission_rejection_restores_prompt_to_composer() { @@ -91,9 +536,17 @@ mod tests { let theme = Theme::named(ThemeName::Dark); let mut vim_state = VimState::new(false); let mut textarea = TextArea::default(); + let mut presentation = TerminalPresentation::new( + false, + crate::terminal_presentation::TerminalPresentationProfile { + osc9_supported: false, + tmux_passthrough: false, + }, + ); handle_runtime_event( TuiEvent::SubmissionRejected { + queued_id: None, prompt: "review @gone.txt".to_string(), message: "bound file is no longer available".to_string(), }, @@ -103,9 +556,322 @@ mod tests { &mut textarea, &mut vim_state, &theme, + &mut presentation, ); assert_eq!(textarea_text(&textarea), "review @gone.txt"); assert_eq!(state.status, AppStatus::Idle); } + + #[test] + fn terminal_notification_for_event_matches_fixed_safe_matrix() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let state = AppState::new( + action_tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + + let cases = [ + ( + TuiEvent::ApprovalNeeded { + key: interaction_key(TuiInteractionKind::Approval, "approval"), + tool: "secret-tool".to_string(), + target: Some("secret-target".to_string()), + preview: Some("secret-preview".to_string()), + }, + "Approval required", + ), + ( + TuiEvent::PermissionApprovalNeeded { + key: interaction_key(TuiInteractionKind::Permission, "permission"), + tool: "secret-tool".to_string(), + target: Some("secret-target".to_string()), + preview: Some("secret-preview".to_string()), + permission_kind: + orca_runtime::runtime_permission::RuntimePermissionRequestKind::UnsandboxedShellRetry, + }, + "Permission approval required", + ), + ( + TuiEvent::UserInputRequested { + key: interaction_key(TuiInteractionKind::UserInput, "input"), + question: "secret-question".to_string(), + choices: vec!["secret-choice".to_string()], + }, + "Input required", + ), + ( + TuiEvent::McpElicitationRequested { + key: interaction_key(TuiInteractionKind::McpElicitation, "mcp"), + server_name: "secret-server".to_string(), + mode: RuntimeMcpElicitationMode::Form, + message: "secret-message".to_string(), + url: Some("secret-url".to_string()), + requested_schema_json: Some("secret-schema".to_string()), + }, + "MCP input required", + ), + ( + TuiEvent::SessionCompleted { + status: "success".to_string(), + }, + "Task completed", + ), + ( + TuiEvent::SessionCompleted { + status: "verification_failed".to_string(), + }, + "Task verification_failed", + ), + ( + TuiEvent::WorkflowNotification { + id: "secret-id".to_string(), + prompt: "secret-prompt".to_string(), + status: "completed".to_string(), + summary: "secret-summary".to_string(), + }, + "Workflow completed", + ), + ( + TuiEvent::WorkflowNotification { + id: "secret-id".to_string(), + prompt: "secret-prompt".to_string(), + status: "failed".to_string(), + summary: "secret-summary".to_string(), + }, + "Workflow failed", + ), + ]; + + for (event, expected) in cases { + let notification = notification_message(&event, &state).expect("notification"); + assert_eq!(notification.message(), expected); + for secret in [ + "secret-tool", + "secret-target", + "secret-preview", + "secret-question", + "secret-choice", + "secret-server", + "secret-message", + "secret-url", + "secret-schema", + "secret-id", + "secret-prompt", + "secret-summary", + ] { + assert!(!notification.message().contains(secret)); + } + } + assert!(notification_message(&TuiEvent::Notice("ignored".to_string()), &state).is_none()); + } + + #[test] + fn terminal_notification_for_event_skips_allowlisted_approval() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state + .approval_allowlist + .insert(AppState::approval_key_target("bash", "cargo test")); + + let event = TuiEvent::ApprovalNeeded { + key: interaction_key(TuiInteractionKind::Approval, "approval"), + tool: "bash".to_string(), + target: Some("cargo test".to_string()), + preview: None, + }; + + assert!(terminal_notification_for_event(&event, &state).is_none()); + } + + #[test] + fn handle_runtime_event_enqueues_only_when_presentation_is_unfocused() { + for (focused, expected_pending) in [(true, 0), (false, 1)] { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut vim_state = VimState::new(false); + let mut textarea = TextArea::default(); + let mut presentation = TerminalPresentation::new( + true, + crate::terminal_presentation::TerminalPresentationProfile { + osc9_supported: true, + tmux_passthrough: false, + }, + ); + presentation.set_focused(focused); + + handle_runtime_event( + TuiEvent::SessionCompleted { + status: "success".to_string(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim_state, + &theme, + &mut presentation, + ); + + assert_eq!(presentation.pending_len_for_test(), expected_pending); + } + } + + #[test] + fn runtime_status_transitions_clear_pending_vim_commands_but_streaming_does_not() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut presentation = test_presentation(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + let mut textarea = TextArea::from(["draft"]); + let mut vim = VimState::new(true); + vim.seed_pending_count_for_test(); + + handle_runtime_event( + TuiEvent::MessageDelta("streaming".to_string()), + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert!(vim.has_pending_command_for_test()); + + handle_runtime_event( + TuiEvent::UserInputRequested { + key: interaction_key(TuiInteractionKind::UserInput, "input"), + question: "question".to_string(), + choices: Vec::new(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert_eq!(state.status, AppStatus::WaitingUserInput); + assert!(!vim.has_pending_command_for_test()); + vim.handle( + tui_textarea::Input { + key: tui_textarea::Key::Char('i'), + ctrl: false, + alt: false, + shift: false, + }, + &mut textarea, + &theme, + ); + assert_eq!(vim.mode, crate::vim::VimMode::Insert); + } + + #[test] + fn runtime_status_transition_flushes_pending_insert_escape_before_new_owner() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut presentation = test_presentation(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + let started = Instant::now(); + let mut vim = + VimState::with_insert_escape(true, Some(VimInsertEscapeSequence::parse("jj").unwrap())); + vim.mode = crate::vim::VimMode::Insert; + let mut textarea = TextArea::from(["draft"]); + textarea.move_cursor(CursorMove::End); + vim.handle_at(vim_insert_input('j'), &mut textarea, &theme, started); + + handle_runtime_event( + TuiEvent::UserInputRequested { + key: interaction_key(TuiInteractionKind::UserInput, "input"), + question: "question".to_string(), + choices: Vec::new(), + }, + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert_eq!(textarea_text(&textarea), "draftj"); + assert!(!vim.has_pending_insert_escape_for_test()); + assert_eq!(state.status, AppStatus::WaitingUserInput); + } + + #[test] + fn idle_workflow_auto_submission_clears_pending_vim_command() { + let (action_tx, action_rx) = mpsc::unbounded(); + let pending = bridge::PendingWorkflowNotifications::new(); + let theme = Theme::named(ThemeName::Dark); + let mut presentation = test_presentation(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state + .pending_workflow_notifications + .push_back(PendingWorkflowNotification { + id: "workflow-1".to_string(), + prompt: "internal workflow".to_string(), + }); + let mut textarea = TextArea::from(["draft"]); + let mut vim = VimState::new(true); + vim.seed_pending_count_for_test(); + + handle_runtime_event( + TuiEvent::Notice("wake".to_string()), + &mut state, + &action_tx, + &pending, + &mut textarea, + &mut vim, + &theme, + &mut presentation, + ); + + assert_eq!(state.status, AppStatus::Running); + assert!(!vim.has_pending_command_for_test()); + assert!(matches!( + action_rx.try_recv(), + Ok(UserAction::SubmitWorkflowNotification(notification)) + if notification.id == "workflow-1" + )); + } } diff --git a/crates/orca-tui/src/runtime_event_projection.rs b/crates/orca-tui/src/runtime_event_projection.rs index 5d22a5a4..bd44ca03 100644 --- a/crates/orca-tui/src/runtime_event_projection.rs +++ b/crates/orca-tui/src/runtime_event_projection.rs @@ -433,7 +433,9 @@ mod tests { status, turn: 3, }), - } if id == "main-session-1" && kind == "agent" && status == "running" + } if id == "main-session-1" + && kind == "agent" + && status == "running" )); } diff --git a/crates/orca-tui/src/selection.rs b/crates/orca-tui/src/selection.rs index 03c1ee05..72b040eb 100644 --- a/crates/orca-tui/src/selection.rs +++ b/crates/orca-tui/src/selection.rs @@ -6,7 +6,7 @@ //! to its content when the transcript scrolls or new messages stream in below. use ratatui::layout::{Position, Rect}; -use ratatui::style::Color; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthChar; @@ -191,16 +191,24 @@ pub fn tmux_passthrough(sequence: &str) -> String { } /// Re-style the display-column range `[col_start, col_end)` of a pre-wrapped -/// line with the theme's selection background, splitting spans at the -/// boundaries. Foreground colors are preserved so highlighted syntax stays -/// readable — only the background changes (like an editor selection). A -/// `None` end highlights through the end of the line. A wide character is -/// selected iff its leading column is inside the range. +/// line with the theme's selection style, splitting spans at the boundaries. +/// The selection style is patched over each source span so syntax colors and +/// modifiers survive. A `None` end highlights through the end of the line. A +/// wide character is selected iff its leading column is inside the range. pub fn apply_selection_to_line( line: Line<'static>, col_start: usize, col_end: Option, - selection_bg: Color, + selection_style: Style, +) -> Line<'static> { + apply_style_to_line_range(line, col_start, col_end, selection_style) +} + +pub(crate) fn apply_style_to_line_range( + line: Line<'static>, + col_start: usize, + col_end: Option, + overlay: Style, ) -> Line<'static> { let col_end = col_end.unwrap_or(usize::MAX); if col_start >= col_end { @@ -232,7 +240,7 @@ pub fn apply_selection_to_line( &mut run, style, run_selected == Some(true), - selection_bg, + overlay, ); } run_selected = Some(selected); @@ -244,7 +252,7 @@ pub fn apply_selection_to_line( &mut run, style, run_selected == Some(true), - selection_bg, + overlay, ); } @@ -259,13 +267,13 @@ fn flush_run( run: &mut String, style: ratatui::style::Style, selected: bool, - selection_bg: Color, + selection_style: Style, ) { if run.is_empty() { return; } let style = if selected { - style.bg(selection_bg) + style.patch(selection_style) } else { style }; @@ -301,13 +309,13 @@ pub fn slice_row_by_columns(text: &str, col_start: usize, col_end: Option #[cfg(test)] mod tests { use ratatui::layout::Rect; - use ratatui::style::{Color, Style}; + use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use super::{ SelectionGranularity, SelectionPos, TranscriptSelection, apply_selection_to_line, - osc52_copy_sequence, screen_to_selection_pos, screen_to_selection_pos_clamped, - slice_row_by_columns, tmux_passthrough, + apply_style_to_line_range, osc52_copy_sequence, screen_to_selection_pos, + screen_to_selection_pos_clamped, slice_row_by_columns, tmux_passthrough, }; const SEL_BG: Color = Color::Rgb(46, 62, 132); @@ -440,7 +448,7 @@ mod tests { Span::styled("abc", Style::default().fg(Color::Red)), Span::styled("def", Style::default().fg(Color::Blue)), ]); - let highlighted = apply_selection_to_line(line, 2, Some(4), SEL_BG); + let highlighted = apply_selection_to_line(line, 2, Some(4), Style::default().bg(SEL_BG)); assert_eq!( rendered(&highlighted), vec![ @@ -453,10 +461,78 @@ mod tests { ); } + #[test] + fn generic_range_overlay_preserves_foregrounds_and_selection_wins() { + let search_bg = Color::Rgb(78, 67, 31); + let selection_bg = Color::Rgb(46, 62, 132); + let line = Line::from(vec![ + Span::styled("let", Style::default().fg(Color::Magenta)), + Span::styled(" 中", Style::default().fg(Color::Green)), + ]); + + let searched = apply_style_to_line_range(line, 0, Some(3), Style::default().bg(search_bg)); + assert!(searched.spans.iter().all(|span| span.style.fg.is_some())); + assert!( + searched + .spans + .iter() + .any(|span| span.style.bg == Some(search_bg)) + ); + + let selected = + apply_style_to_line_range(searched, 1, Some(4), Style::default().bg(selection_bg)); + assert!( + selected + .spans + .iter() + .any(|span| span.style.bg == Some(selection_bg)) + ); + } + + #[test] + fn generic_range_overlay_uses_wide_character_leading_columns() { + let highlighted = apply_style_to_line_range( + Line::from("A中B"), + 1, + Some(3), + Style::default().bg(Color::Blue), + ); + assert!( + highlighted + .spans + .iter() + .any(|span| { span.content == "中" && span.style.bg == Some(Color::Blue) }) + ); + } + + #[test] + fn selection_background_preserves_multiple_syntax_foregrounds() { + let line = Line::from(vec![ + Span::styled("let", Style::default().fg(Color::Magenta)), + Span::styled(" value = ", Style::default().fg(Color::White)), + Span::styled("\"hello\"", Style::default().fg(Color::Green)), + ]); + + let highlighted = apply_selection_to_line(line, 0, None, Style::default().bg(SEL_BG)); + + let foregrounds: Vec> = + highlighted.spans.iter().map(|span| span.style.fg).collect(); + assert_eq!( + foregrounds, + vec![Some(Color::Magenta), Some(Color::White), Some(Color::Green),] + ); + assert!( + highlighted + .spans + .iter() + .all(|span| span.style.bg == Some(SEL_BG)) + ); + } + #[test] fn apply_selection_open_end_highlights_through_end_of_line() { let line = Line::from(Span::raw("abcdef")); - let highlighted = apply_selection_to_line(line, 3, None, SEL_BG); + let highlighted = apply_selection_to_line(line, 3, None, Style::default().bg(SEL_BG)); let flags: Vec = highlighted .spans .iter() @@ -470,7 +546,7 @@ mod tests { fn apply_selection_treats_wide_characters_by_leading_column() { // "世" occupies columns 0-1, "界" columns 2-3, "x" column 4. let line = Line::from(Span::raw("世界x")); - let highlighted = apply_selection_to_line(line, 2, Some(4), SEL_BG); + let highlighted = apply_selection_to_line(line, 2, Some(4), Style::default().bg(SEL_BG)); assert_eq!( rendered(&highlighted), vec![ @@ -481,6 +557,27 @@ mod tests { ); } + #[test] + fn monochrome_selection_reverses_while_preserving_source_modifiers() { + let source = Style::default().add_modifier(Modifier::BOLD); + let selected = Style::default().add_modifier(Modifier::REVERSED); + let line = Line::from(Span::styled("abc", source)); + + let rendered = apply_selection_to_line(line, 0, None, selected); + assert!( + rendered.spans[0] + .style + .add_modifier + .contains(Modifier::BOLD) + ); + assert!( + rendered.spans[0] + .style + .add_modifier + .contains(Modifier::REVERSED) + ); + } + #[test] fn slice_row_by_columns_handles_ascii_wide_chars_and_open_end() { assert_eq!(slice_row_by_columns("abcdef", 2, Some(4)), "cd"); diff --git a/crates/orca-tui/src/setup_actions.rs b/crates/orca-tui/src/setup_actions.rs index 7fbc9c38..d0148886 100644 --- a/crates/orca-tui/src/setup_actions.rs +++ b/crates/orca-tui/src/setup_actions.rs @@ -5,16 +5,22 @@ use std::sync::{Arc, Mutex}; use crossterm::event::{Event, KeyCode, KeyEvent}; use tui_textarea::{Input, TextArea}; -use orca_core::config::RunConfig; -use orca_core::config::file::save_api_key; +use orca_core::config::file::{ + UserConfigSaveError, UserPreferencePatch, save_api_key, save_user_preferences, +}; +use orca_core::config::{RunConfig, ThemeName}; +use orca_core::model::ModelSelection; use crate::composer_textarea::{make_setup_textarea, make_textarea}; +use crate::onboarding::{OnboardingError, OnboardingStep, SaveOutcome}; use crate::theme::Theme; use crate::types::{AppState, AppStatus, ChatMessage, UserAction}; use crate::vim::VimState; +#[derive(Debug, PartialEq)] pub(crate) enum SetupFlow { Continue, + PreviewTheme(ThemeName), Exit(i32), } @@ -31,42 +37,86 @@ pub(crate) fn handle_setup_key( theme: &Theme, initial_prompt: Option, ) -> io::Result { - match state.setup_step { - 0 => match key.code { - KeyCode::Enter => { - state.setup_step = 1; - *textarea = make_setup_textarea(theme); - } - KeyCode::Esc => { - return Ok(SetupFlow::Exit(0)); + handle_setup_key_with_savers( + ev, + key, + state, + config, + shared_config, + action_tx, + textarea, + vim_state, + theme, + initial_prompt, + save_api_key, + save_user_preferences, + ) +} + +#[allow(clippy::too_many_arguments)] +fn handle_setup_key_with_savers( + ev: &Event, + key: &KeyEvent, + state: &mut AppState, + config: &mut RunConfig, + shared_config: &Arc>, + action_tx: &mpsc::Sender, + textarea: &mut TextArea, + vim_state: &VimState, + theme: &Theme, + initial_prompt: Option, + save_auth: AuthSave, + save_preferences: PreferencesSave, +) -> io::Result +where + AuthSave: FnOnce(&str) -> Result<(), UserConfigSaveError>, + PreferencesSave: FnOnce(&UserPreferencePatch) -> Result<(), UserConfigSaveError>, +{ + match key.code { + KeyCode::Esc => return Ok(SetupFlow::Exit(0)), + KeyCode::Up | KeyCode::Char('k') + if matches!( + state.onboarding.step(), + OnboardingStep::Provider | OnboardingStep::Model | OnboardingStep::Theme + ) => + { + return Ok(match state.onboarding.move_previous() { + Some(theme) => SetupFlow::PreviewTheme(theme), + None => SetupFlow::Continue, + }); + } + KeyCode::Down | KeyCode::Char('j') + if matches!( + state.onboarding.step(), + OnboardingStep::Provider | OnboardingStep::Model | OnboardingStep::Theme + ) => + { + return Ok(match state.onboarding.move_next() { + Some(theme) => SetupFlow::PreviewTheme(theme), + None => SetupFlow::Continue, + }); + } + KeyCode::Enter => match state.onboarding.step() { + OnboardingStep::Welcome + | OnboardingStep::Provider + | OnboardingStep::Model + | OnboardingStep::Theme => { + state.onboarding.advance(); } - _ => {} - }, - 1 => match key.code { - KeyCode::Enter => { - let lines: Vec = textarea.lines().to_vec(); - let key_input = lines.join("").trim().to_string(); - if !key_input.is_empty() { - save_api_key(&key_input); - config.api_key = Some(key_input.clone()); - if let Ok(mut cfg) = shared_config.lock() { - cfg.api_key = Some(key_input); - } - state.setup_step = 2; + OnboardingStep::ApiKey => { + let api_key = textarea.lines().join(""); + state.onboarding.set_api_key(api_key); + if state.onboarding.advance() { + *textarea = make_setup_textarea(theme); } } - KeyCode::Esc => { - return Ok(SetupFlow::Exit(0)); - } - _ => { - textarea.input(Input::from(ev.clone())); + OnboardingStep::Review => { + apply_review(state, config, shared_config, save_auth, save_preferences); } - }, - 2 => match key.code { - KeyCode::Enter => { + OnboardingStep::Complete => { state.set_status(AppStatus::Idle); - state.setup_step = 0; *textarea = make_textarea(vim_state, theme); + state.sync_vim_mode(vim_state); if let Some(prompt) = initial_prompt { state.push_message(ChatMessage::User(prompt.clone())); @@ -74,12 +124,757 @@ pub(crate) fn handle_setup_key( let _ = action_tx.send(UserAction::Submit(prompt)); } } - KeyCode::Esc => { - return Ok(SetupFlow::Exit(0)); - } - _ => {} }, + _ if state.onboarding.step() == OnboardingStep::ApiKey => { + textarea.input(Input::from(ev.clone())); + if textarea.lines().iter().any(|line| !line.trim().is_empty()) { + state.onboarding.clear_api_key_error(); + } + } _ => {} } Ok(SetupFlow::Continue) } + +fn apply_review( + state: &mut AppState, + config: &mut RunConfig, + shared_config: &Arc>, + save_auth: AuthSave, + save_preferences: PreferencesSave, +) where + AuthSave: FnOnce(&str) -> Result<(), UserConfigSaveError>, + PreferencesSave: FnOnce(&UserPreferencePatch) -> Result<(), UserConfigSaveError>, +{ + let patch = match state.onboarding.validate_review() { + Ok(patch) => patch, + Err(_) => return, + }; + let provider = state.onboarding.draft_provider(); + let model = state.onboarding.draft_model().to_string(); + let theme = state.onboarding.selected_theme(); + let selection = match ModelSelection::parse(Some(model.clone())) { + Ok(selection) => selection, + Err(_) => { + state + .onboarding + .set_error(OnboardingError::UnsupportedModel); + return; + } + }; + let Ok(mut shared) = shared_config.lock() else { + state + .onboarding + .set_error(OnboardingError::SharedConfigUnavailable); + return; + }; + let Some(api_key) = state.onboarding.take_api_key() else { + drop(shared); + state.onboarding.set_error(OnboardingError::MissingApiKey); + return; + }; + + config.provider = provider; + config.model = selection.clone(); + config.theme = theme; + shared.provider = provider; + shared.model = selection; + shared.theme = theme; + shared.api_key = Some(api_key.clone()); + config.api_key = Some(api_key); + drop(shared); + + state.model_name = model; + state.auth_configured = true; + let auth_outcome = SaveOutcome::from(save_auth( + config + .api_key + .as_deref() + .expect("validated API key must remain in runtime config"), + )); + let preferences_outcome = SaveOutcome::from(save_preferences(&patch)); + if !state + .onboarding + .finish_review(auth_outcome, preferences_outcome) + { + state.onboarding.set_error(OnboardingError::InvalidStep); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyModifiers; + use orca_core::config::file::UserConfigSaveError; + use orca_core::config::{ProviderKind, ThemeName}; + use orca_core::model::ModelSelection; + + use crate::composer_textarea::textarea_text; + use crate::onboarding::{OnboardingError, OnboardingStep, SaveOutcome}; + + #[derive(Default)] + struct SaveCalls { + auth: usize, + preferences: usize, + auth_key_matches_expected: bool, + } + + fn setup_harness() -> ( + AppState, + RunConfig, + Arc>, + mpsc::Sender, + mpsc::Receiver, + TextArea<'static>, + VimState, + ) { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut config = crate::test_support::test_run_config(); + config.provider = ProviderKind::DeepSeek; + config.model = ModelSelection::from_unchecked(Some("auto".to_string())); + config.theme = ThemeName::Auto; + config.api_key = None; + let shared = Arc::new(Mutex::new(config.clone())); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "auto".to_string(), + "/tmp".to_string(), + ); + state.status = AppStatus::Setup; + state.initialize_onboarding(&config); + let theme = Theme::named(ThemeName::Dark); + let textarea = make_setup_textarea(&theme); + ( + state, + config, + shared, + action_tx, + action_rx, + textarea, + VimState::new(false), + ) + } + + #[allow(clippy::too_many_arguments)] + fn press_setup_key( + code: KeyCode, + state: &mut AppState, + config: &mut RunConfig, + shared: &Arc>, + action_tx: &mpsc::Sender, + textarea: &mut TextArea, + vim: &VimState, + theme: &Theme, + initial_prompt: Option, + calls: &Arc>, + auth_result: Result<(), UserConfigSaveError>, + preferences_result: Result<(), UserConfigSaveError>, + ) -> SetupFlow { + let key = KeyEvent::new(code, KeyModifiers::NONE); + let auth_calls = Arc::clone(calls); + let preference_calls = Arc::clone(calls); + handle_setup_key_with_savers( + &Event::Key(key), + &key, + state, + config, + shared, + action_tx, + textarea, + vim, + theme, + initial_prompt, + move |api_key| { + let mut calls = auth_calls.lock().unwrap(); + calls.auth += 1; + calls.auth_key_matches_expected = api_key == "sk-test-secret"; + auth_result + }, + move |_| { + preference_calls.lock().unwrap().preferences += 1; + preferences_result + }, + ) + .unwrap() + } + + #[allow(clippy::too_many_arguments)] + fn drive_to_review( + state: &mut AppState, + config: &mut RunConfig, + shared: &Arc>, + action_tx: &mpsc::Sender, + textarea: &mut TextArea, + vim: &VimState, + theme: &Theme, + calls: &Arc>, + ) { + assert_eq!(state.onboarding.step(), OnboardingStep::Welcome); + assert_eq!( + press_setup_key( + KeyCode::Enter, + state, + config, + shared, + action_tx, + textarea, + vim, + theme, + None, + calls, + Ok(()), + Ok(()), + ), + SetupFlow::Continue, + ); + assert_eq!(state.onboarding.step(), OnboardingStep::Provider); + + press_setup_key( + KeyCode::Enter, + state, + config, + shared, + action_tx, + textarea, + vim, + theme, + None, + calls, + Ok(()), + Ok(()), + ); + assert_eq!(state.onboarding.step(), OnboardingStep::ApiKey); + + textarea.insert_str(" sk-test-secret "); + press_setup_key( + KeyCode::Enter, + state, + config, + shared, + action_tx, + textarea, + vim, + theme, + None, + calls, + Ok(()), + Ok(()), + ); + assert_eq!(state.onboarding.step(), OnboardingStep::Model); + assert_eq!(textarea_text(textarea), ""); + + press_setup_key( + KeyCode::Down, + state, + config, + shared, + action_tx, + textarea, + vim, + theme, + None, + calls, + Ok(()), + Ok(()), + ); + press_setup_key( + KeyCode::Enter, + state, + config, + shared, + action_tx, + textarea, + vim, + theme, + None, + calls, + Ok(()), + Ok(()), + ); + assert_eq!(state.onboarding.step(), OnboardingStep::Theme); + + for _ in 0..3 { + assert!(matches!( + press_setup_key( + KeyCode::Char('j'), + state, + config, + shared, + action_tx, + textarea, + vim, + theme, + None, + calls, + Ok(()), + Ok(()), + ), + SetupFlow::PreviewTheme(_), + )); + } + press_setup_key( + KeyCode::Enter, + state, + config, + shared, + action_tx, + textarea, + vim, + theme, + None, + calls, + Ok(()), + Ok(()), + ); + assert_eq!(state.onboarding.step(), OnboardingStep::Review); + } + + #[test] + fn enter_advances_exact_wizard_sequence_without_early_persistence() { + let (mut state, mut config, shared, action_tx, _rx, mut textarea, vim) = setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + + drive_to_review( + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + &calls, + ); + + assert!(config.api_key.is_none()); + assert!(shared.lock().unwrap().api_key.is_none()); + assert!(!state.auth_configured); + assert_eq!(calls.lock().unwrap().auth, 0); + assert_eq!(calls.lock().unwrap().preferences, 0); + assert!( + !state + .onboarding + .review_rows() + .join("\n") + .contains("sk-test-secret") + ); + } + + #[test] + fn empty_api_key_stays_on_api_key_without_persistence() { + let (mut state, mut config, shared, action_tx, _rx, mut textarea, vim) = setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + state.onboarding.set_step_for_test(OnboardingStep::ApiKey); + textarea.insert_str(" "); + + press_setup_key( + KeyCode::Enter, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Ok(()), + Ok(()), + ); + + assert_eq!(state.onboarding.step(), OnboardingStep::ApiKey); + assert_eq!( + state.onboarding.review_error(), + Some(OnboardingError::MissingApiKey) + ); + assert!(config.api_key.is_none()); + assert_eq!(calls.lock().unwrap().auth, 0); + assert_eq!(calls.lock().unwrap().preferences, 0); + } + + #[test] + fn typing_after_missing_api_key_clears_the_safe_error() { + let (mut state, mut config, shared, action_tx, _rx, mut textarea, vim) = setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + state.onboarding.set_step_for_test(OnboardingStep::ApiKey); + + press_setup_key( + KeyCode::Enter, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Ok(()), + Ok(()), + ); + assert_eq!( + state.onboarding.review_error(), + Some(OnboardingError::MissingApiKey), + ); + + press_setup_key( + KeyCode::Char('x'), + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Ok(()), + Ok(()), + ); + + assert_eq!(state.onboarding.review_error(), None); + assert_eq!(textarea_text(&textarea), "x"); + } + + #[test] + fn api_key_textarea_accepts_j_and_k_as_regular_input() { + let (mut state, mut config, shared, action_tx, _rx, mut textarea, vim) = setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + state.onboarding.set_step_for_test(OnboardingStep::ApiKey); + + for key in [KeyCode::Char('j'), KeyCode::Char('k')] { + press_setup_key( + key, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Ok(()), + Ok(()), + ); + } + + assert_eq!(textarea_text(&textarea), "jk"); + } + + #[test] + fn selection_keys_wrap_only_options_and_only_theme_previews() { + let keys = [ + KeyCode::Up, + KeyCode::Char('k'), + KeyCode::Down, + KeyCode::Char('j'), + ]; + for step in OnboardingStep::ALL { + for key in keys { + let (mut state, mut config, shared, action_tx, _rx, mut textarea, vim) = + setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + state.onboarding.set_step_for_test(step); + let before = state.onboarding.option_rows(); + let flow = press_setup_key( + key, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Ok(()), + Ok(()), + ); + if !matches!( + step, + OnboardingStep::Provider | OnboardingStep::Model | OnboardingStep::Theme + ) { + assert_eq!(state.onboarding.option_rows(), before, "{step:?} {key:?}"); + } + assert_eq!( + matches!(flow, SetupFlow::PreviewTheme(_)), + step == OnboardingStep::Theme, + "{step:?} {key:?}", + ); + } + } + } + + #[test] + fn review_applies_memory_before_independent_persistence_results() { + let (mut state, mut config, shared, action_tx, action_rx, mut textarea, vim) = + setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + drive_to_review( + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + &calls, + ); + + assert_eq!( + press_setup_key( + KeyCode::Enter, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Err(UserConfigSaveError::WriteFailed), + Ok(()), + ), + SetupFlow::Continue, + ); + assert_eq!(state.onboarding.step(), OnboardingStep::Complete); + assert_eq!(config.provider, ProviderKind::DeepSeek); + assert_eq!(config.model.display_name(), "deepseek-v4-flash"); + assert_eq!(config.theme, ThemeName::Solarized); + assert_eq!(config.api_key.as_deref(), Some("sk-test-secret")); + let shared = shared.lock().unwrap(); + assert_eq!(shared.provider, config.provider); + assert_eq!(shared.model, config.model); + assert_eq!(shared.theme, config.theme); + assert_eq!(shared.api_key, config.api_key); + drop(shared); + assert!(state.auth_configured); + assert_eq!(state.model_name, "deepseek-v4-flash"); + assert_eq!( + state.onboarding.auth_outcome(), + &SaveOutcome::Failed(UserConfigSaveError::WriteFailed), + ); + assert_eq!(state.onboarding.preferences_outcome(), &SaveOutcome::Saved); + assert_eq!(calls.lock().unwrap().auth, 1); + assert_eq!(calls.lock().unwrap().preferences, 1); + assert!(calls.lock().unwrap().auth_key_matches_expected); + assert!(state.onboarding.api_key().is_none()); + assert!(state.onboarding.take_api_key().is_none()); + assert!(action_rx.try_recv().is_err()); + } + + #[test] + fn poisoned_shared_config_keeps_review_transaction_unapplied() { + let (mut state, mut config, shared, action_tx, _rx, mut textarea, vim) = setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + drive_to_review( + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + &calls, + ); + let poison = Arc::clone(&shared); + let _ = std::thread::spawn(move || { + let _guard = poison.lock().unwrap(); + panic!("poison shared config"); + }) + .join(); + + press_setup_key( + KeyCode::Enter, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Ok(()), + Ok(()), + ); + + assert_eq!(state.onboarding.step(), OnboardingStep::Review); + assert!(config.api_key.is_none()); + assert!(!state.auth_configured); + assert_eq!( + state.onboarding.review_error(), + Some(OnboardingError::SharedConfigUnavailable), + ); + assert_eq!(calls.lock().unwrap().auth, 0); + assert_eq!(calls.lock().unwrap().preferences, 0); + assert!(state.onboarding.api_key().is_some()); + } + + #[test] + fn production_review_path_consumes_the_staged_api_key() { + let source = include_str!("setup_actions.rs"); + let consuming_call = ["state.onboarding.", "take_api_key()"].concat(); + let cloning_call = [".api_key()", ".map(str::to_string)"].concat(); + let runtime_clone = ["api_key", ".clone()"].concat(); + + assert!( + source.contains(&consuming_call), + "apply_review must transfer the staged credential", + ); + assert!( + !source.contains(&cloning_call), + "apply_review must not clone the onboarding draft credential", + ); + assert_eq!( + source.matches(&runtime_clone).count(), + 1, + "current and shared runtime config require exactly one credential clone", + ); + } + + #[test] + fn invalid_review_draft_stays_staged_without_apply_or_save() { + let (mut state, mut config, shared, action_tx, _rx, mut textarea, vim) = setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + state.onboarding.set_step_for_test(OnboardingStep::Review); + state.onboarding.set_api_key("sk-staged".to_string()); + state + .onboarding + .set_model_for_test("unsupported-model".to_string()); + let config_before = config.clone(); + let shared_before = shared.lock().unwrap().clone(); + + press_setup_key( + KeyCode::Enter, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Ok(()), + Ok(()), + ); + + assert_eq!(state.onboarding.step(), OnboardingStep::Review); + assert_eq!( + state.onboarding.review_error(), + Some(OnboardingError::UnsupportedModel), + ); + assert_eq!(config.provider, config_before.provider); + assert_eq!(config.model, config_before.model); + assert_eq!(config.theme, config_before.theme); + assert_eq!(config.api_key, config_before.api_key); + let shared_after = shared.lock().unwrap(); + assert_eq!(shared_after.provider, shared_before.provider); + assert_eq!(shared_after.model, shared_before.model); + assert_eq!(shared_after.theme, shared_before.theme); + assert_eq!(shared_after.api_key, shared_before.api_key); + assert_eq!(calls.lock().unwrap().auth, 0); + assert_eq!(calls.lock().unwrap().preferences, 0); + } + + #[test] + fn esc_from_every_step_exits_without_persistence() { + for step in OnboardingStep::ALL { + let (mut state, mut config, shared, action_tx, _rx, mut textarea, vim) = + setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + state.onboarding.set_step_for_test(step); + state.onboarding.set_api_key("sk-test-secret".to_string()); + + assert_eq!( + press_setup_key( + KeyCode::Esc, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + None, + &calls, + Ok(()), + Ok(()), + ), + SetupFlow::Exit(0), + "{step:?}", + ); + assert!(config.api_key.is_none()); + assert_eq!(calls.lock().unwrap().auth, 0); + assert_eq!(calls.lock().unwrap().preferences, 0); + } + } + + #[test] + fn complete_enter_dispatches_initial_prompt_once_after_setup() { + let (mut state, mut config, shared, action_tx, action_rx, mut textarea, vim) = + setup_harness(); + let theme = Theme::named(ThemeName::Dark); + let calls = Arc::new(Mutex::new(SaveCalls::default())); + drive_to_review( + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + &calls, + ); + press_setup_key( + KeyCode::Enter, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + Some("start".to_string()), + &calls, + Ok(()), + Ok(()), + ); + assert!(action_rx.try_recv().is_err()); + + press_setup_key( + KeyCode::Enter, + &mut state, + &mut config, + &shared, + &action_tx, + &mut textarea, + &vim, + &theme, + Some("start".to_string()), + &calls, + Ok(()), + Ok(()), + ); + + assert_eq!(state.status, AppStatus::Running); + assert_eq!(state.messages.len(), 1); + assert!(matches!(&state.messages[0], ChatMessage::User(message) if message == "start")); + assert!( + matches!(action_rx.try_recv(), Ok(UserAction::Submit(prompt)) if prompt == "start") + ); + assert!(action_rx.try_recv().is_err()); + } +} diff --git a/crates/orca-tui/src/shortcuts.rs b/crates/orca-tui/src/shortcuts.rs index e58f9d39..9198e874 100644 --- a/crates/orca-tui/src/shortcuts.rs +++ b/crates/orca-tui/src/shortcuts.rs @@ -2,7 +2,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ShortcutScope { Global, Idle, @@ -25,7 +25,7 @@ pub struct ResolvedShortcutHint { pub has_registered_binding: bool, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ShortcutContext { Global, Idle, @@ -33,7 +33,7 @@ pub enum ShortcutContext { Approval, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ShortcutAction { Global(GlobalShortcut), Idle(IdleShortcut), @@ -62,19 +62,21 @@ impl KeyBinding { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum GlobalShortcut { Cancel, + OpenTranscriptSearch, ToggleShortcuts, ScrollBottom, ScrollTop, ClearScreen, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum IdleShortcut { Submit, Newline, + EditLatestQueued, HistoryPrevious, HistoryNext, ScrollUp, @@ -87,10 +89,13 @@ pub enum IdleShortcut { ExpandToolOutput, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum RunningShortcut { BackgroundCurrentTurn, Interrupt, + SubmitQueued, + Newline, + EditLatestQueued, ScrollUp, ScrollDown, PageUp, @@ -99,7 +104,7 @@ pub enum RunningShortcut { HalfPageDown, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ApprovalShortcut { SelectAllow, SelectDeny, @@ -114,6 +119,10 @@ const GLOBAL_BINDINGS: &[(GlobalShortcut, KeyBinding)] = &[ GlobalShortcut::Cancel, KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL), ), + ( + GlobalShortcut::OpenTranscriptSearch, + KeyBinding::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + ), ( GlobalShortcut::ToggleShortcuts, KeyBinding::new(KeyCode::F(1), KeyModifiers::NONE), @@ -153,6 +162,10 @@ const IDLE_BINDINGS: &[(IdleShortcut, KeyBinding)] = &[ IdleShortcut::Newline, KeyBinding::new(KeyCode::Char('j'), KeyModifiers::CONTROL), ), + ( + IdleShortcut::EditLatestQueued, + KeyBinding::new(KeyCode::Up, KeyModifiers::ALT), + ), ( IdleShortcut::HistoryPrevious, KeyBinding::new(KeyCode::Char('p'), KeyModifiers::CONTROL), @@ -208,6 +221,26 @@ const RUNNING_BINDINGS: &[(RunningShortcut, KeyBinding)] = &[ RunningShortcut::Interrupt, KeyBinding::new(KeyCode::Char('g'), KeyModifiers::CONTROL), ), + ( + RunningShortcut::SubmitQueued, + KeyBinding::new(KeyCode::Enter, KeyModifiers::NONE), + ), + ( + RunningShortcut::Newline, + KeyBinding::new(KeyCode::Enter, KeyModifiers::SHIFT), + ), + ( + RunningShortcut::Newline, + KeyBinding::new(KeyCode::Enter, KeyModifiers::ALT), + ), + ( + RunningShortcut::Newline, + KeyBinding::new(KeyCode::Char('j'), KeyModifiers::CONTROL), + ), + ( + RunningShortcut::EditLatestQueued, + KeyBinding::new(KeyCode::Up, KeyModifiers::ALT), + ), ( RunningShortcut::ScrollUp, KeyBinding::new(KeyCode::Up, KeyModifiers::NONE), @@ -285,6 +318,174 @@ const APPROVAL_BINDINGS: &[(ApprovalShortcut, KeyBinding)] = &[ ), ]; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct LegacyBinding { + pub(crate) context: ShortcutContext, + pub(crate) action: ShortcutAction, + pub(crate) key: KeyCode, + pub(crate) modifiers: KeyModifiers, +} + +impl LegacyBinding { + const fn new(context: ShortcutContext, action: ShortcutAction, binding: KeyBinding) -> Self { + Self { + context, + action, + key: binding.key, + modifiers: binding.modifiers, + } + } + + #[cfg(test)] + pub(crate) const fn as_key_event(self) -> KeyEvent { + KeyEvent::new(self.key, self.modifiers) + } +} + +pub(crate) fn configurable_legacy_bindings() -> impl Iterator { + GLOBAL_BINDINGS + .iter() + .map(|(action, binding)| { + LegacyBinding::new( + ShortcutContext::Global, + ShortcutAction::Global(*action), + *binding, + ) + }) + .chain(IDLE_BINDINGS.iter().map(|(action, binding)| { + LegacyBinding::new( + ShortcutContext::Idle, + ShortcutAction::Idle(*action), + *binding, + ) + })) + .chain(RUNNING_BINDINGS.iter().map(|(action, binding)| { + LegacyBinding::new( + ShortcutContext::Running, + ShortcutAction::Running(*action), + *binding, + ) + })) + .chain( + APPROVAL_BINDINGS + .iter() + .filter(|(action, _)| { + matches!( + action, + ApprovalShortcut::SelectAllow + | ApprovalShortcut::SelectDeny + | ApprovalShortcut::ToggleSelection + | ApprovalShortcut::Confirm + ) + }) + .map(|(action, binding)| { + LegacyBinding::new( + ShortcutContext::Approval, + ShortcutAction::Approval(*action), + *binding, + ) + }), + ) +} + +const CONFIGURABLE_ACTIONS: &[ShortcutAction] = &[ + ShortcutAction::Global(GlobalShortcut::Cancel), + ShortcutAction::Global(GlobalShortcut::OpenTranscriptSearch), + ShortcutAction::Global(GlobalShortcut::ToggleShortcuts), + ShortcutAction::Global(GlobalShortcut::ScrollBottom), + ShortcutAction::Global(GlobalShortcut::ScrollTop), + ShortcutAction::Global(GlobalShortcut::ClearScreen), + ShortcutAction::Idle(IdleShortcut::Submit), + ShortcutAction::Idle(IdleShortcut::Newline), + ShortcutAction::Idle(IdleShortcut::EditLatestQueued), + ShortcutAction::Idle(IdleShortcut::HistoryPrevious), + ShortcutAction::Idle(IdleShortcut::HistoryNext), + ShortcutAction::Idle(IdleShortcut::ScrollUp), + ShortcutAction::Idle(IdleShortcut::ScrollDown), + ShortcutAction::Idle(IdleShortcut::PageUp), + ShortcutAction::Idle(IdleShortcut::PageDown), + ShortcutAction::Idle(IdleShortcut::HalfPageUp), + ShortcutAction::Idle(IdleShortcut::HalfPageDown), + ShortcutAction::Idle(IdleShortcut::Backtrack), + ShortcutAction::Idle(IdleShortcut::ExpandToolOutput), + ShortcutAction::Running(RunningShortcut::BackgroundCurrentTurn), + ShortcutAction::Running(RunningShortcut::Interrupt), + ShortcutAction::Running(RunningShortcut::SubmitQueued), + ShortcutAction::Running(RunningShortcut::Newline), + ShortcutAction::Running(RunningShortcut::EditLatestQueued), + ShortcutAction::Running(RunningShortcut::ScrollUp), + ShortcutAction::Running(RunningShortcut::ScrollDown), + ShortcutAction::Running(RunningShortcut::PageUp), + ShortcutAction::Running(RunningShortcut::PageDown), + ShortcutAction::Running(RunningShortcut::HalfPageUp), + ShortcutAction::Running(RunningShortcut::HalfPageDown), + ShortcutAction::Approval(ApprovalShortcut::SelectAllow), + ShortcutAction::Approval(ApprovalShortcut::SelectDeny), + ShortcutAction::Approval(ApprovalShortcut::ToggleSelection), + ShortcutAction::Approval(ApprovalShortcut::Confirm), +]; + +pub(crate) fn configurable_actions() -> impl Iterator { + CONFIGURABLE_ACTIONS.iter().copied() +} + +impl ShortcutAction { + pub(crate) const fn configurable_id(self) -> Option<&'static str> { + Some(match self { + Self::Global(GlobalShortcut::Cancel) => "global.cancel", + Self::Global(GlobalShortcut::OpenTranscriptSearch) => "global.open-transcript-search", + Self::Global(GlobalShortcut::ToggleShortcuts) => "global.toggle-shortcuts", + Self::Global(GlobalShortcut::ScrollBottom) => "global.scroll-bottom", + Self::Global(GlobalShortcut::ScrollTop) => "global.scroll-top", + Self::Global(GlobalShortcut::ClearScreen) => "global.clear-screen", + Self::Idle(IdleShortcut::Submit) => "idle.submit", + Self::Idle(IdleShortcut::Newline) => "idle.newline", + Self::Idle(IdleShortcut::EditLatestQueued) => "idle.edit-latest-queued", + Self::Idle(IdleShortcut::HistoryPrevious) => "idle.history-previous", + Self::Idle(IdleShortcut::HistoryNext) => "idle.history-next", + Self::Idle(IdleShortcut::ScrollUp) => "idle.scroll-up", + Self::Idle(IdleShortcut::ScrollDown) => "idle.scroll-down", + Self::Idle(IdleShortcut::PageUp) => "idle.page-up", + Self::Idle(IdleShortcut::PageDown) => "idle.page-down", + Self::Idle(IdleShortcut::HalfPageUp) => "idle.half-page-up", + Self::Idle(IdleShortcut::HalfPageDown) => "idle.half-page-down", + Self::Idle(IdleShortcut::Backtrack) => "idle.backtrack", + Self::Idle(IdleShortcut::ExpandToolOutput) => "idle.expand-tool-output", + Self::Running(RunningShortcut::BackgroundCurrentTurn) => { + "running.background-current-turn" + } + Self::Running(RunningShortcut::Interrupt) => "running.interrupt", + Self::Running(RunningShortcut::SubmitQueued) => "running.submit-queued", + Self::Running(RunningShortcut::Newline) => "running.newline", + Self::Running(RunningShortcut::EditLatestQueued) => "running.edit-latest-queued", + Self::Running(RunningShortcut::ScrollUp) => "running.scroll-up", + Self::Running(RunningShortcut::ScrollDown) => "running.scroll-down", + Self::Running(RunningShortcut::PageUp) => "running.page-up", + Self::Running(RunningShortcut::PageDown) => "running.page-down", + Self::Running(RunningShortcut::HalfPageUp) => "running.half-page-up", + Self::Running(RunningShortcut::HalfPageDown) => "running.half-page-down", + Self::Approval(ApprovalShortcut::SelectAllow) => "approval.select-allow", + Self::Approval(ApprovalShortcut::SelectDeny) => "approval.select-deny", + Self::Approval(ApprovalShortcut::ToggleSelection) => "approval.toggle-selection", + Self::Approval(ApprovalShortcut::Confirm) => "approval.confirm", + Self::Approval(ApprovalShortcut::Approve | ApprovalShortcut::Deny) => return None, + }) + } + + pub(crate) const fn context(self) -> ShortcutContext { + match self { + Self::Global(_) => ShortcutContext::Global, + Self::Idle(_) => ShortcutContext::Idle, + Self::Running(_) => ShortcutContext::Running, + Self::Approval(_) => ShortcutContext::Approval, + } + } +} + +pub(crate) fn action_for_id(id: &str) -> Option { + configurable_actions().find(|action| action.configurable_id() == Some(id)) +} + pub fn resolve_shortcut(context: ShortcutContext, event: KeyEvent) -> Option { if let Some(shortcut) = global_shortcut(event) { return Some(ShortcutAction::Global(shortcut)); @@ -324,6 +525,13 @@ pub fn shortcut_hints() -> impl Iterator { } pub fn shortcut_lines(scopes: &[ShortcutScope]) -> Vec> { + shortcut_lines_with_keymap(scopes, &crate::keybindings::Keymap::built_in()) +} + +pub(crate) fn shortcut_lines_with_keymap( + scopes: &[ShortcutScope], + keymap: &crate::keybindings::Keymap, +) -> Vec> { let mut lines = Vec::new(); let sections = [ (ShortcutScope::Global, "Global"), @@ -344,13 +552,15 @@ pub fn shortcut_lines(scopes: &[ShortcutScope]) -> Vec> { title, Style::default().fg(Color::Cyan), ))); - for hint in shortcut_hints().filter(|hint| hint.scope == section_scope) { + for descriptor in + shortcut_descriptors().filter(|descriptor| descriptor.scope == section_scope) + { + let Some(keys) = keymap.descriptor_keys(descriptor) else { + continue; + }; lines.push(Line::from(vec![ - Span::styled( - format!(" {:<18}", hint.keys), - Style::default().fg(Color::Yellow), - ), - Span::styled(hint.action, Style::default().fg(Color::White)), + Span::styled(format!(" {keys:<18}"), Style::default().fg(Color::Yellow)), + Span::styled(descriptor.label, Style::default().fg(Color::White)), ])); } } @@ -359,6 +569,11 @@ pub fn shortcut_lines(scopes: &[ShortcutScope]) -> Vec> { } pub const SHORTCUT_HINTS: &[ShortcutHint] = &[ + ShortcutHint { + scope: ShortcutScope::Global, + keys: "ctrl+f", + action: "find in transcript", + }, ShortcutHint { scope: ShortcutScope::Global, keys: "F1 / ctrl+k", @@ -394,6 +609,11 @@ pub const SHORTCUT_HINTS: &[ShortcutHint] = &[ keys: "alt+enter / shift+enter", action: "insert newline", }, + ShortcutHint { + scope: ShortcutScope::Idle, + keys: "alt+up", + action: "edit latest queued message", + }, ShortcutHint { scope: ShortcutScope::Idle, keys: "up/down / ctrl+p/ctrl+n", @@ -429,6 +649,21 @@ pub const SHORTCUT_HINTS: &[ShortcutHint] = &[ keys: "esc / ctrl+g", action: "interrupt current turn", }, + ShortcutHint { + scope: ShortcutScope::Running, + keys: "enter", + action: "queue follow-up", + }, + ShortcutHint { + scope: ShortcutScope::Running, + keys: "alt+enter / shift+enter", + action: "insert newline", + }, + ShortcutHint { + scope: ShortcutScope::Running, + keys: "alt+up", + action: "edit latest queued message", + }, ShortcutHint { scope: ShortcutScope::Running, keys: "up/down", @@ -476,6 +711,244 @@ pub const SHORTCUT_HINTS: &[ShortcutHint] = &[ }, ]; +#[derive(Clone, Copy, Debug)] +pub(crate) struct ShortcutDescriptor { + pub(crate) scope: ShortcutScope, + pub(crate) legacy_keys: &'static str, + pub(crate) label: &'static str, + pub(crate) actions: &'static [ShortcutAction], +} + +const OPEN_SEARCH: &[ShortcutAction] = + &[ShortcutAction::Global(GlobalShortcut::OpenTranscriptSearch)]; +const TOGGLE_SHORTCUTS: &[ShortcutAction] = + &[ShortcutAction::Global(GlobalShortcut::ToggleShortcuts)]; +const CANCEL: &[ShortcutAction] = &[ShortcutAction::Global(GlobalShortcut::Cancel)]; +const SCROLL_EDGES: &[ShortcutAction] = &[ + ShortcutAction::Global(GlobalShortcut::ScrollTop), + ShortcutAction::Global(GlobalShortcut::ScrollBottom), +]; +const CLEAR_SCREEN: &[ShortcutAction] = &[ShortcutAction::Global(GlobalShortcut::ClearScreen)]; +const IDLE_SUBMIT: &[ShortcutAction] = &[ShortcutAction::Idle(IdleShortcut::Submit)]; +const IDLE_NEWLINE: &[ShortcutAction] = &[ShortcutAction::Idle(IdleShortcut::Newline)]; +const IDLE_EDIT_QUEUED: &[ShortcutAction] = &[ShortcutAction::Idle(IdleShortcut::EditLatestQueued)]; +const IDLE_HISTORY: &[ShortcutAction] = &[ + ShortcutAction::Idle(IdleShortcut::HistoryPrevious), + ShortcutAction::Idle(IdleShortcut::HistoryNext), +]; +const IDLE_PAGE: &[ShortcutAction] = &[ + ShortcutAction::Idle(IdleShortcut::PageUp), + ShortcutAction::Idle(IdleShortcut::PageDown), +]; +const IDLE_HALF_PAGE: &[ShortcutAction] = &[ + ShortcutAction::Idle(IdleShortcut::HalfPageUp), + ShortcutAction::Idle(IdleShortcut::HalfPageDown), +]; +const IDLE_BACKTRACK: &[ShortcutAction] = &[ShortcutAction::Idle(IdleShortcut::Backtrack)]; +const IDLE_EXPAND: &[ShortcutAction] = &[ShortcutAction::Idle(IdleShortcut::ExpandToolOutput)]; +const RUNNING_BACKGROUND: &[ShortcutAction] = &[ShortcutAction::Running( + RunningShortcut::BackgroundCurrentTurn, +)]; +const RUNNING_INTERRUPT: &[ShortcutAction] = &[ShortcutAction::Running(RunningShortcut::Interrupt)]; +const RUNNING_SUBMIT: &[ShortcutAction] = &[ShortcutAction::Running(RunningShortcut::SubmitQueued)]; +const RUNNING_NEWLINE: &[ShortcutAction] = &[ShortcutAction::Running(RunningShortcut::Newline)]; +const RUNNING_EDIT_QUEUED: &[ShortcutAction] = + &[ShortcutAction::Running(RunningShortcut::EditLatestQueued)]; +const RUNNING_SCROLL: &[ShortcutAction] = &[ + ShortcutAction::Running(RunningShortcut::ScrollUp), + ShortcutAction::Running(RunningShortcut::ScrollDown), +]; +const RUNNING_PAGE: &[ShortcutAction] = &[ + ShortcutAction::Running(RunningShortcut::PageUp), + ShortcutAction::Running(RunningShortcut::PageDown), +]; +const RUNNING_HALF_PAGE: &[ShortcutAction] = &[ + ShortcutAction::Running(RunningShortcut::HalfPageUp), + ShortcutAction::Running(RunningShortcut::HalfPageDown), +]; +const APPROVAL_MOVE: &[ShortcutAction] = &[ + ShortcutAction::Approval(ApprovalShortcut::SelectAllow), + ShortcutAction::Approval(ApprovalShortcut::SelectDeny), +]; +const APPROVAL_TOGGLE: &[ShortcutAction] = + &[ShortcutAction::Approval(ApprovalShortcut::ToggleSelection)]; +const APPROVAL_CONFIRM: &[ShortcutAction] = &[ShortcutAction::Approval(ApprovalShortcut::Confirm)]; + +const SHORTCUT_DESCRIPTORS: &[ShortcutDescriptor] = &[ + ShortcutDescriptor { + scope: ShortcutScope::Global, + legacy_keys: "ctrl+f", + label: "find in transcript", + actions: OPEN_SEARCH, + }, + ShortcutDescriptor { + scope: ShortcutScope::Global, + legacy_keys: "F1 / ctrl+k", + label: "show or hide shortcuts", + actions: TOGGLE_SHORTCUTS, + }, + ShortcutDescriptor { + scope: ShortcutScope::Global, + legacy_keys: "ctrl+c", + label: "cancel and quit", + actions: CANCEL, + }, + ShortcutDescriptor { + scope: ShortcutScope::Global, + legacy_keys: "ctrl+home/end", + label: "jump to top or bottom", + actions: SCROLL_EDGES, + }, + ShortcutDescriptor { + scope: ShortcutScope::Global, + legacy_keys: "ctrl+l", + label: "clear screen", + actions: CLEAR_SCREEN, + }, + ShortcutDescriptor { + scope: ShortcutScope::Global, + legacy_keys: "shift+tab", + label: "cycle approval mode", + actions: &[], + }, + ShortcutDescriptor { + scope: ShortcutScope::Idle, + legacy_keys: "enter", + label: "send message", + actions: IDLE_SUBMIT, + }, + ShortcutDescriptor { + scope: ShortcutScope::Idle, + legacy_keys: "alt+enter / shift+enter", + label: "insert newline", + actions: IDLE_NEWLINE, + }, + ShortcutDescriptor { + scope: ShortcutScope::Idle, + legacy_keys: "alt+up", + label: "edit latest queued message", + actions: IDLE_EDIT_QUEUED, + }, + ShortcutDescriptor { + scope: ShortcutScope::Idle, + legacy_keys: "up/down / ctrl+p/ctrl+n", + label: "previous or next prompt", + actions: IDLE_HISTORY, + }, + ShortcutDescriptor { + scope: ShortcutScope::Idle, + legacy_keys: "pgup/pgdn", + label: "scroll one page", + actions: IDLE_PAGE, + }, + ShortcutDescriptor { + scope: ShortcutScope::Idle, + legacy_keys: "ctrl+u / ctrl+d", + label: "scroll half page", + actions: IDLE_HALF_PAGE, + }, + ShortcutDescriptor { + scope: ShortcutScope::Idle, + legacy_keys: "esc", + label: "backtrack previous prompt", + actions: IDLE_BACKTRACK, + }, + ShortcutDescriptor { + scope: ShortcutScope::Idle, + legacy_keys: "e", + label: "expand latest tool output", + actions: IDLE_EXPAND, + }, + ShortcutDescriptor { + scope: ShortcutScope::Running, + legacy_keys: "ctrl+b", + label: "background current turn", + actions: RUNNING_BACKGROUND, + }, + ShortcutDescriptor { + scope: ShortcutScope::Running, + legacy_keys: "esc / ctrl+g", + label: "interrupt current turn", + actions: RUNNING_INTERRUPT, + }, + ShortcutDescriptor { + scope: ShortcutScope::Running, + legacy_keys: "enter", + label: "queue follow-up", + actions: RUNNING_SUBMIT, + }, + ShortcutDescriptor { + scope: ShortcutScope::Running, + legacy_keys: "alt+enter / shift+enter", + label: "insert newline", + actions: RUNNING_NEWLINE, + }, + ShortcutDescriptor { + scope: ShortcutScope::Running, + legacy_keys: "alt+up", + label: "edit latest queued message", + actions: RUNNING_EDIT_QUEUED, + }, + ShortcutDescriptor { + scope: ShortcutScope::Running, + legacy_keys: "up/down", + label: "scroll one line", + actions: RUNNING_SCROLL, + }, + ShortcutDescriptor { + scope: ShortcutScope::Running, + legacy_keys: "pgup/pgdn", + label: "scroll one page", + actions: RUNNING_PAGE, + }, + ShortcutDescriptor { + scope: ShortcutScope::Running, + legacy_keys: "ctrl+u / ctrl+d", + label: "scroll half page", + actions: RUNNING_HALF_PAGE, + }, + ShortcutDescriptor { + scope: ShortcutScope::Approval, + legacy_keys: "up/down/j/k", + label: "move selection", + actions: APPROVAL_MOVE, + }, + ShortcutDescriptor { + scope: ShortcutScope::Approval, + legacy_keys: "tab", + label: "toggle selection", + actions: APPROVAL_TOGGLE, + }, + ShortcutDescriptor { + scope: ShortcutScope::Approval, + legacy_keys: "enter", + label: "confirm selected action", + actions: APPROVAL_CONFIRM, + }, + ShortcutDescriptor { + scope: ShortcutScope::Approval, + legacy_keys: "1/2/3", + label: "allow options", + actions: &[], + }, + ShortcutDescriptor { + scope: ShortcutScope::Approval, + legacy_keys: "4", + label: "deny", + actions: &[], + }, + ShortcutDescriptor { + scope: ShortcutScope::Approval, + legacy_keys: "y/A/a/n", + label: "legacy direct keys", + actions: &[], + }, +]; + +pub(crate) fn shortcut_descriptors() -> impl Iterator { + SHORTCUT_DESCRIPTORS.iter() +} + fn scope_has_registered_binding(scope: ShortcutScope) -> bool { match scope { ShortcutScope::Global => !GLOBAL_BINDINGS.is_empty(), @@ -492,7 +965,10 @@ fn match_binding(event: KeyEvent, bindings: &[(T, KeyBinding)]) -> Opti .map(|(action, _)| *action) } -fn normalize_key_parts(key: KeyCode, mut modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) { +pub(crate) fn normalize_key_parts( + key: KeyCode, + mut modifiers: KeyModifiers, +) -> (KeyCode, KeyModifiers) { let KeyCode::Char(ch) = key else { return (key, modifiers); }; @@ -603,6 +1079,58 @@ mod tests { ); } + #[test] + fn queued_message_shortcuts_are_context_specific() { + assert_eq!( + resolve_shortcut(ShortcutContext::Idle, key(KeyCode::Up, KeyModifiers::ALT)), + Some(ShortcutAction::Idle(IdleShortcut::EditLatestQueued)) + ); + assert_eq!( + resolve_shortcut( + ShortcutContext::Running, + key(KeyCode::Up, KeyModifiers::ALT) + ), + Some(ShortcutAction::Running(RunningShortcut::EditLatestQueued)) + ); + assert_eq!( + resolve_shortcut( + ShortcutContext::Running, + key(KeyCode::Enter, KeyModifiers::NONE) + ), + Some(ShortcutAction::Running(RunningShortcut::SubmitQueued)) + ); + for modifiers in [KeyModifiers::SHIFT, KeyModifiers::ALT] { + assert_eq!( + resolve_shortcut(ShortcutContext::Running, key(KeyCode::Enter, modifiers)), + Some(ShortcutAction::Running(RunningShortcut::Newline)) + ); + } + assert_eq!( + resolve_shortcut( + ShortcutContext::Running, + key(KeyCode::Char('j'), KeyModifiers::CONTROL) + ), + Some(ShortcutAction::Running(RunningShortcut::Newline)) + ); + } + + #[test] + fn global_ctrl_f_opens_transcript_search() { + assert_eq!( + global_shortcut(key(KeyCode::Char('f'), KeyModifiers::CONTROL)), + Some(GlobalShortcut::OpenTranscriptSearch) + ); + } + + #[test] + fn search_shortcut_hint_is_backed_by_a_binding() { + assert!(shortcut_hints().any(|hint| { + hint.scope == ShortcutScope::Global + && hint.keys == "ctrl+f" + && hint.has_registered_binding + })); + } + #[test] fn shortcut_resolver_prioritizes_global_bindings() { assert_eq!( @@ -646,4 +1174,32 @@ mod tests { ); } } + + #[test] + fn dynamic_shortcut_lines_keep_defaults_and_render_replacements() { + let built_in = crate::keybindings::Keymap::built_in(); + let default_text = shortcut_lines_with_keymap(&[ShortcutScope::Idle], &built_in) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + let legacy_text = shortcut_lines(&[ShortcutScope::Idle]) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert_eq!(default_text, legacy_text); + + let custom = crate::keybindings::parse_keymap( + br#"{"version":1,"bindings":{"idle.submit":["ctrl+s"],"idle.backtrack":[]}}"#, + ) + .unwrap(); + let custom_text = shortcut_lines_with_keymap(&[ShortcutScope::Idle], &custom) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(custom_text.contains("ctrl+s")); + assert!(!custom_text.contains("backtrack previous prompt")); + } } diff --git a/crates/orca-tui/src/slash_command_actions.rs b/crates/orca-tui/src/slash_command_actions.rs index 76ef2430..735e89c8 100644 --- a/crates/orca-tui/src/slash_command_actions.rs +++ b/crates/orca-tui/src/slash_command_actions.rs @@ -6,7 +6,9 @@ use orca_core::config::RunConfig; use orca_core::model::ModelSelection; use orca_runtime::history; -use crate::commands::{self, GoalSlashCommand, SlashCommand, TrustSlashCommand}; +use crate::commands::{ + self, DoctorSlashCommand, GoalSlashCommand, SlashCommand, TrustSlashCommand, +}; use crate::types::{AppState, ChatMessage, UserAction}; pub(crate) enum SlashOutcome { @@ -20,12 +22,18 @@ pub(crate) fn handle_slash_command( state: &mut AppState, action_tx: &mpsc::Sender, ) -> Option { - let cwd = config - .cwd - .as_deref() - .map(std::path::Path::to_path_buf) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - let command = commands::parse_with_cwd(text, &cwd)?; + let command = if let Some(command) = commands::parse(text) { + command + } else if commands::has_builtin_name(text) { + return None; + } else { + let configured_cwd = config.cwd.as_deref().map(std::path::Path::to_path_buf); + parse_command_with_cwd(text, || { + configured_cwd + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) + })? + }; match command { SlashCommand::Model(Some(model)) => match commands::validate_model(&model) { Ok(()) => { @@ -60,6 +68,21 @@ pub(crate) fn handle_slash_command( config, ))); } + SlashCommand::Doctor(command) => match command { + DoctorSlashCommand::Report => { + state.push_message(ChatMessage::System( + state.doctor_report(std::time::Instant::now()), + )); + } + DoctorSlashCommand::ToggleFps => { + let enabled = state.toggle_fps_hud(); + state.push_message(ChatMessage::System(fps_hud_message(enabled).to_string())); + } + DoctorSlashCommand::SetFps(enabled) => { + state.set_fps_hud(enabled); + state.push_message(ChatMessage::System(fps_hud_message(enabled).to_string())); + } + }, SlashCommand::Mode(Some(mode)) => match parse_approval_mode(&mode) { Some(approval_mode) => { config.approval_mode = approval_mode; @@ -225,6 +248,11 @@ pub(crate) fn handle_slash_command( }, SlashCommand::Trust(trust_command) => { use orca_core::config::folder_trust::{self, TrustLevel}; + let cwd = config + .cwd + .as_deref() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); match trust_command { TrustSlashCommand::Show => { if folder_trust::is_trusted(&cwd) { @@ -268,6 +296,16 @@ pub(crate) fn handle_slash_command( Some(SlashOutcome::Continue) } +fn parse_command_with_cwd( + text: &str, + cwd: impl FnOnce() -> std::path::PathBuf, +) -> Option { + if commands::has_builtin_name(text) { + return None; + } + commands::parse_with_cwd(text, &cwd()) +} + pub(crate) fn parse_approval_mode(mode: &str) -> Option { match mode { "suggest" => Some(ApprovalMode::Suggest), @@ -277,3 +315,139 @@ pub(crate) fn parse_approval_mode(mode: &str) -> Option { _ => None, } } + +fn fps_hud_message(enabled: bool) -> &'static str { + if enabled { + "FPS HUD enabled." + } else { + "FPS HUD disabled." + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Theme; + use crate::types::{AppStatus, PanelMode}; + use crate::vim::{VimMode, VimState}; + use orca_core::config::ThemeName; + + fn diagnostic_state() -> ( + AppState, + mpsc::Receiver, + RunConfig, + Arc>, + mpsc::Sender, + ) { + let (action_tx, action_rx) = mpsc::unbounded(); + let state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let config = crate::test_support::test_run_config(); + let shared = Arc::new(Mutex::new(config.clone())); + (state, action_rx, config, shared, action_tx) + } + + #[test] + fn doctor_report_pushes_one_message_and_preserves_session_state() { + let (mut state, _rx, mut config, shared, action_tx) = diagnostic_state(); + state.status = AppStatus::Running; + state.panel_mode = PanelMode::Agents; + state.vim_mode = Some(VimMode::Visual); + let before = (state.status, state.panel_mode, state.vim_mode); + + assert!( + handle_slash_command("/doctor", &mut config, &shared, &mut state, &action_tx,) + .is_some() + ); + + assert_eq!((state.status, state.panel_mode, state.vim_mode), before); + assert!(matches!( + state.messages.last(), + Some(ChatMessage::System(report)) if report.starts_with("Orca diagnostics\n") + )); + } + + #[test] + fn doctor_fps_toggle_and_explicit_forms_are_session_only_and_idempotent() { + let (mut state, _rx, mut config, shared, action_tx) = diagnostic_state(); + let before = orca_core::config::format_config_show(&config); + + for (command, expected, message) in [ + ("/doctor fps", true, "FPS HUD enabled."), + ("/doctor fps on", true, "FPS HUD enabled."), + ("/doctor fps off", false, "FPS HUD disabled."), + ("/doctor fps off", false, "FPS HUD disabled."), + ] { + handle_slash_command(command, &mut config, &shared, &mut state, &action_tx) + .expect("recognized doctor command"); + assert_eq!(state.fps_hud_enabled, expected); + assert!(matches!( + state.messages.last(), + Some(ChatMessage::System(text)) if text == message + )); + assert_eq!(orca_core::config::format_config_show(&config), before); + assert_eq!( + orca_core::config::format_config_show(&shared.lock().unwrap()), + before, + ); + } + } + + #[test] + fn submitted_doctor_command_is_cleared_by_existing_submit_contract() { + use crate::composer_textarea::{make_textarea_with_text, textarea_text}; + use crate::idle_submit_actions::handle_idle_submit; + + let (mut state, _rx, mut config, shared, action_tx) = diagnostic_state(); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = make_textarea_with_text("/doctor", &vim, &theme); + + assert!(handle_idle_submit( + &mut textarea, + &mut vim, + &theme, + &mut state, + &mut config, + &shared, + &action_tx, + )); + + assert_eq!(textarea_text(&textarea), ""); + assert!(matches!( + state.messages.last(), + Some(ChatMessage::System(report)) if report.starts_with("Orca diagnostics\n") + )); + } + + #[test] + fn built_in_slash_commands_are_parsed_before_cwd_discovery() { + let source = include_str!("slash_command_actions.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .expect("production slash command source"); + let parse_builtin = source + .find("commands::parse(text)") + .expect("built-in parser must run first"); + let current_dir = source + .find("std::env::current_dir()") + .expect("dynamic aliases may still need cwd discovery"); + assert!(parse_builtin < current_dir); + } + + #[test] + fn malformed_builtin_commands_do_not_invoke_cwd_resolver() { + let mut resolver_called = false; + let command = parse_command_with_cwd("/doctor extra", || { + resolver_called = true; + std::path::PathBuf::from("/tmp") + }); + + assert_eq!(command, None); + assert!(!resolver_called); + } +} diff --git a/crates/orca-tui/src/slash_menu_actions.rs b/crates/orca-tui/src/slash_menu_actions.rs index 7fcb6f67..d2da15bb 100644 --- a/crates/orca-tui/src/slash_menu_actions.rs +++ b/crates/orca-tui/src/slash_menu_actions.rs @@ -277,6 +277,7 @@ fn select_slash_menu_command( *textarea = make_textarea(vim_state, theme); match orca_runtime::history::list_sessions(20) { Ok(sessions) if !sessions.is_empty() => { + state.reset_queued_user_messages(); state.session_picker_sessions = sessions; state.session_picker_selected = 0; state.status = AppStatus::SessionPicker; diff --git a/crates/orca-tui/src/status_key_actions.rs b/crates/orca-tui/src/status_key_actions.rs index a63530bd..c053fc9a 100644 --- a/crates/orca-tui/src/status_key_actions.rs +++ b/crates/orca-tui/src/status_key_actions.rs @@ -5,22 +5,33 @@ use std::sync::{Arc, Mutex}; use crossterm::event::{Event, KeyEvent}; use tui_textarea::TextArea; -use orca_core::config::RunConfig; +use orca_core::config::{RunConfig, ThemeName}; use orca_runtime::history::SessionTranscript; -use crate::approval_dialog_actions::handle_approval_dialog_key; -use crate::idle_key_actions::handle_idle_key; +use crate::approval_dialog_actions::{ + handle_approval_dialog_key, handle_approval_dialog_key_dynamic, handle_approval_shortcut, +}; +use crate::idle_key_actions::{ + handle_idle_key, handle_idle_key_dynamic, handle_idle_shortcut_invocation, +}; +use crate::keybindings::{ + InputOwnerFingerprint, KeymapRuntime, ShortcutInvocation, ShortcutResolution, +}; use crate::operation_controller::TuiOperationInterrupt; +use crate::queued_input_actions::{ + handle_running_key, handle_running_key_dynamic, handle_running_shortcut_invocation, +}; use crate::running_actions::handle_running_shortcut; use crate::session_picker_actions::handle_session_picker_key; use crate::setup_actions::{SetupFlow, handle_setup_key}; use crate::shortcuts::{RunningShortcut, ShortcutAction, ShortcutContext, resolve_shortcut}; use crate::theme::Theme; use crate::types::{AppState, AppStatus, UserAction}; -use crate::vim::VimState; +use crate::vim::{VimState, VimTranscriptSearchIntent}; pub(crate) enum StatusKeyFlow { Continue, + PreviewTheme(ThemeName), Exit(i32), } @@ -44,6 +55,7 @@ where F: FnOnce() -> io::Result<()>, { if state.status == AppStatus::Setup { + vim_state.cancel_pending_command(); return match handle_setup_key( ev, key, @@ -57,11 +69,13 @@ where initial_prompt, )? { SetupFlow::Continue => Ok(StatusKeyFlow::Continue), + SetupFlow::PreviewTheme(theme) => Ok(StatusKeyFlow::PreviewTheme(theme)), SetupFlow::Exit(code) => Ok(StatusKeyFlow::Exit(code)), }; } if state.status == AppStatus::SessionPicker { + vim_state.cancel_pending_command(); handle_session_picker_key( key, state, @@ -74,10 +88,37 @@ where } if state.status == AppStatus::WaitingApproval { + vim_state.cancel_pending_command(); handle_approval_dialog_key(key, state, action_tx); return Ok(StatusKeyFlow::Continue); } + if matches!( + state.status, + AppStatus::Idle | AppStatus::Running | AppStatus::WaitingUserInput + ) && let Some(intent) = vim_state.transcript_search_intent(key.code) + { + let handled = match intent { + VimTranscriptSearchIntent::Open => { + state.open_transcript_search(); + true + } + VimTranscriptSearchIntent::Next if state.transcript_search.has_query() => { + state.search_next(); + true + } + VimTranscriptSearchIntent::Previous if state.transcript_search.has_query() => { + state.search_previous(); + true + } + _ => false, + }; + if handled { + vim_state.cancel_pending_command(); + return Ok(StatusKeyFlow::Continue); + } + } + if matches!(state.status, AppStatus::Idle | AppStatus::WaitingUserInput) { handle_idle_key( ev, @@ -93,19 +134,173 @@ where return Ok(StatusKeyFlow::Continue); } - if state.status == AppStatus::Running - && let Some(ShortcutAction::Running(shortcut)) = - resolve_shortcut(ShortcutContext::Running, *key) - { - handle_running_shortcut(shortcut, state, action_tx, operation); + if state.status == AppStatus::Running { + handle_running_key( + ev, key, state, config, action_tx, operation, textarea, vim_state, theme, + ); } - if state.status == AppStatus::Compacting - && let Some(ShortcutAction::Running(shortcut)) = + if state.status == AppStatus::Compacting { + vim_state.cancel_pending_command(); + if let Some(ShortcutAction::Running(shortcut)) = resolve_shortcut(ShortcutContext::Running, *key) - && compacting_shortcut_allowed(shortcut) + && compacting_shortcut_allowed(shortcut) + { + handle_running_shortcut(shortcut, state, action_tx, operation); + } + } + + Ok(StatusKeyFlow::Continue) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_status_key_dynamic( + ev: &Event, + key: &KeyEvent, + now: std::time::Instant, + owner: InputOwnerFingerprint, + keymap: &mut KeymapRuntime, + invocation: Option, + state: &mut AppState, + config: &mut RunConfig, + shared_config: &Arc>, + action_tx: &mpsc::Sender, + operation: &impl TuiOperationInterrupt, + preloaded_transcript: &Arc>>, + textarea: &mut TextArea, + vim_state: &mut VimState, + theme: &Theme, + initial_prompt: Option, + clear_terminal: F, +) -> io::Result +where + F: FnOnce() -> io::Result<()>, +{ + if state.status == AppStatus::Setup || state.status == AppStatus::SessionPicker { + return handle_status_key( + ev, + key, + state, + config, + shared_config, + action_tx, + operation, + preloaded_transcript, + textarea, + vim_state, + theme, + initial_prompt, + clear_terminal, + ); + } + + if state.status == AppStatus::WaitingApproval { + vim_state.cancel_pending_command(); + if let Some(ShortcutInvocation { + action: ShortcutAction::Approval(shortcut), + .. + }) = invocation + { + handle_approval_shortcut(shortcut, state, action_tx); + } else { + handle_approval_dialog_key_dynamic(key, now, owner, keymap, state, action_tx); + } + return Ok(StatusKeyFlow::Continue); + } + + if let Some(invocation) = invocation { + match invocation.action { + ShortcutAction::Idle(_) => { + handle_idle_shortcut_invocation( + invocation, + state, + config, + shared_config, + action_tx, + textarea, + vim_state, + theme, + ); + } + ShortcutAction::Running(shortcut) => { + if state.status != AppStatus::Compacting || compacting_shortcut_allowed(shortcut) { + handle_running_shortcut_invocation( + invocation, state, config, action_tx, operation, textarea, vim_state, theme, + ); + } + } + ShortcutAction::Global(_) | ShortcutAction::Approval(_) => {} + } + return Ok(StatusKeyFlow::Continue); + } + + if matches!( + state.status, + AppStatus::Idle | AppStatus::Running | AppStatus::WaitingUserInput + ) && let Some(intent) = vim_state.transcript_search_intent(key.code) { - handle_running_shortcut(shortcut, state, action_tx, operation); + let handled = match intent { + VimTranscriptSearchIntent::Open => { + state.open_transcript_search(); + true + } + VimTranscriptSearchIntent::Next if state.transcript_search.has_query() => { + state.search_next(); + true + } + VimTranscriptSearchIntent::Previous if state.transcript_search.has_query() => { + state.search_previous(); + true + } + _ => false, + }; + if handled { + vim_state.cancel_pending_command(); + return Ok(StatusKeyFlow::Continue); + } + } + + if matches!(state.status, AppStatus::Idle | AppStatus::WaitingUserInput) { + handle_idle_key_dynamic( + ev, + key, + now, + owner, + keymap, + state, + config, + shared_config, + action_tx, + textarea, + vim_state, + theme, + ); + return Ok(StatusKeyFlow::Continue); + } + + if state.status == AppStatus::Running { + handle_running_key_dynamic( + ev, key, now, owner, keymap, state, config, action_tx, operation, textarea, vim_state, + theme, + ); + } + + if state.status == AppStatus::Compacting { + vim_state.cancel_pending_command(); + match keymap.resolve_new_context(owner, *key, now) { + ShortcutResolution::Action(invocation) => { + if let ShortcutAction::Running(shortcut) = invocation.action + && compacting_shortcut_allowed(shortcut) + { + handle_running_shortcut_invocation( + invocation, state, config, action_tx, operation, textarea, vim_state, theme, + ); + } + } + ShortcutResolution::Pending + | ShortcutResolution::RetryCurrentKey + | ShortcutResolution::NoMatch => {} + } } Ok(StatusKeyFlow::Continue) @@ -120,7 +315,10 @@ fn compacting_shortcut_allowed(shortcut: RunningShortcut) -> bool { | RunningShortcut::PageDown | RunningShortcut::HalfPageUp | RunningShortcut::HalfPageDown => true, - RunningShortcut::BackgroundCurrentTurn => false, + RunningShortcut::BackgroundCurrentTurn + | RunningShortcut::SubmitQueued + | RunningShortcut::Newline + | RunningShortcut::EditLatestQueued => false, } } @@ -135,6 +333,51 @@ mod tests { WorkflowConfig, }; use orca_core::model::ModelSelection; + use orca_file_search::{MatchKind, SearchMatch, SearchPhase}; + use orca_runtime::mentions::MentionCandidate; + use std::path::PathBuf; + + #[test] + fn normal_setup_status_route_propagates_preview_theme() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.status = AppStatus::Setup; + state + .onboarding + .set_step_for_test(crate::onboarding::OnboardingStep::Theme); + let mut config = config(); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let theme = Theme::named(ThemeName::Dark); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(false); + let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + let preloaded = Arc::new(Mutex::new(None)); + + let flow = handle_status_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + + assert!(matches!(flow, StatusKeyFlow::PreviewTheme(ThemeName::Dark))); + } fn config() -> RunConfig { RunConfig { @@ -166,12 +409,512 @@ mod tests { workflows: WorkflowConfig::default(), theme: ThemeName::Dark, vim_mode: false, + vim_insert_escape: None, update_check: false, desktop_notifications: false, + terminal_notifications: false, auto_memory: false, } } + fn prepare_two_search_matches(state: &mut AppState) { + state.push_message(crate::types::ChatMessage::System("alpha one".to_string())); + state.push_message(crate::types::ChatMessage::System("alpha two".to_string())); + let theme = Theme::named(ThemeName::Dark); + let messages = &state.messages; + let revisions = &state.message_revisions; + state.transcript_render_cache.prepare( + messages, + revisions, + crate::transcript_view::TranscriptRenderContext::new(&theme, 40, 0, false), + |_, message, theme, width, tick, force_expand| { + crate::ui::build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); + state.open_transcript_search(); + state.replace_transcript_search_query("alpha"); + state.refresh_transcript_search(); + } + + #[allow(clippy::too_many_arguments)] + fn press_status_key( + code: KeyCode, + modifiers: KeyModifiers, + state: &mut AppState, + config: &mut RunConfig, + shared: &Arc>, + action_tx: &mpsc::Sender, + operation: &TestOperationInterrupt, + textarea: &mut TextArea, + vim: &mut VimState, + theme: &Theme, + ) { + let key = KeyEvent::new(code, modifiers); + let preloaded = Arc::new(Mutex::new(None)); + handle_status_key( + &Event::Key(key), + &key, + state, + config, + shared, + action_tx, + operation, + &preloaded, + textarea, + vim, + theme, + None, + || Ok(()), + ) + .unwrap(); + } + + #[test] + fn running_composer_edits_newlines_queues_and_keeps_scroll_shortcuts() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + state.total_lines = 20; + state.visible_height = 5; + state.scroll_offset = 10; + state.auto_scroll = false; + let mut config = config(); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = TextArea::default(); + + press_status_key( + KeyCode::Char('x'), + KeyModifiers::NONE, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(textarea.lines(), &["x".to_string()]); + + press_status_key( + KeyCode::Enter, + KeyModifiers::SHIFT, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(textarea.lines(), &["x".to_string(), String::new()]); + + assert!(textarea.insert_str("/compact")); + press_status_key( + KeyCode::Enter, + KeyModifiers::NONE, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(state.queued_user_messages[0].visible_text(), "x\n/compact"); + assert!(textarea.is_empty()); + assert_eq!(state.status, AppStatus::Running); + assert!(action_rx.try_recv().is_err()); + + press_status_key( + KeyCode::Up, + KeyModifiers::NONE, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(state.scroll_offset, 9); + assert!(action_rx.try_recv().is_err()); + } + + #[test] + fn running_vim_edits_and_queued_submit_uses_existing_reset_mode() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + let mut config = config(); + config.vim_mode = true; + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(true); + vim.mode = crate::vim::VimMode::Insert; + let mut textarea = TextArea::default(); + + press_status_key( + KeyCode::Char('x'), + KeyModifiers::NONE, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(textarea.lines(), &["x".to_string()]); + + press_status_key( + KeyCode::Enter, + KeyModifiers::NONE, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(vim.mode, crate::vim::VimMode::Normal); + assert!(textarea.is_empty()); + assert!(action_rx.try_recv().is_err()); + } + + #[test] + fn running_mention_enter_selects_before_queueing() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/workspace".to_string(), + ); + state.enter_running(); + state.mention.candidates = vec![MentionCandidate::from_file_match(&SearchMatch { + root: PathBuf::from("/workspace"), + path: "item.rs".to_string(), + kind: MatchKind::File, + score: 42, + indices: vec![0], + })]; + state.mention.phase = Some(SearchPhase::Complete); + let mut config = config(); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = crate::composer_textarea::make_textarea_with_text("@ite", &vim, &theme); + + press_status_key( + KeyCode::Enter, + KeyModifiers::NONE, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!( + crate::composer_textarea::textarea_text(&textarea), + "@item.rs " + ); + assert_eq!(state.mention_bindings.bindings().len(), 1); + assert!(state.queued_user_messages.is_empty()); + + press_status_key( + KeyCode::Enter, + KeyModifiers::NONE, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!( + state.queued_user_messages[0] + .submission_bindings() + .bindings() + .len(), + 1 + ); + assert!(action_rx.try_recv().is_err()); + } + + #[test] + fn idle_alt_up_restores_but_waiting_input_keeps_queue_owned() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let queued = || { + crate::queued_input::QueuedUserMessage::from_composer( + "queued".to_string(), + Vec::new(), + orca_runtime::mentions::MentionBindings::default(), + ) + .unwrap() + }; + state.enqueue_user_message(queued()).unwrap(); + let mut config = config(); + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(false); + let mut textarea = TextArea::from(["draft"]); + + press_status_key( + KeyCode::Up, + KeyModifiers::ALT, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(crate::composer_textarea::textarea_text(&textarea), "queued"); + assert!(state.queued_user_messages.is_empty()); + + state.enqueue_user_message(queued()).unwrap(); + state.set_status(AppStatus::WaitingUserInput); + press_status_key( + KeyCode::Up, + KeyModifiers::ALT, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + assert_eq!(state.queued_user_messages.len(), 1); + assert_eq!(crate::composer_textarea::textarea_text(&textarea), "queued"); + } + + #[test] + fn vim_slash_opens_search_in_every_conversation_status_without_composer_edit() { + for status in [ + AppStatus::Idle, + AppStatus::Running, + AppStatus::WaitingUserInput, + ] { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.set_status(status); + let mut config = config(); + config.vim_mode = true; + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let preloaded = Arc::new(Mutex::new(None)); + let mut textarea = TextArea::from(["draft"]); + let mut vim = VimState::new(true); + let theme = Theme::named(ThemeName::Dark); + let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE); + + handle_status_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + + assert!(state.transcript_search.open, "{status:?}"); + assert_eq!(textarea.lines(), &["draft".to_string()]); + assert_eq!(operation.call_count(), 0); + } + } + + #[test] + fn vim_search_intent_clears_pending_prefix_before_opening_search() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = config(); + config.vim_mode = true; + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let preloaded = Arc::new(Mutex::new(None)); + let mut textarea = TextArea::from(["draft"]); + let mut vim = VimState::new(true); + vim.seed_pending_count_for_test(); + let theme = Theme::named(ThemeName::Dark); + let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE); + + handle_status_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + + assert!(state.transcript_search.open); + assert!(!vim.has_pending_command_for_test()); + } + + #[test] + fn vim_n_and_shift_n_navigate_closed_running_search_without_interrupt() { + let (action_tx, action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.enter_running(); + prepare_two_search_matches(&mut state); + state.close_transcript_search(); + let first = state.transcript_search.active_ordinal(); + let mut config = config(); + config.vim_mode = true; + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let preloaded = Arc::new(Mutex::new(None)); + let mut textarea = TextArea::from(["draft"]); + let mut vim = VimState::new(true); + let theme = Theme::named(ThemeName::Dark); + + for code in [KeyCode::Char('n'), KeyCode::Char('N')] { + let key = KeyEvent::new(code, KeyModifiers::NONE); + handle_status_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + if code == KeyCode::Char('n') { + assert_ne!(state.transcript_search.active_ordinal(), first); + } else { + assert_eq!(state.transcript_search.active_ordinal(), first); + } + } + assert_eq!(operation.call_count(), 0); + assert!(action_rx.try_recv().is_err()); + } + + #[test] + fn vim_insert_slash_remains_composer_text() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + let mut config = config(); + config.vim_mode = true; + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let preloaded = Arc::new(Mutex::new(None)); + let mut textarea = TextArea::default(); + let mut vim = VimState::new(true); + vim.mode = crate::vim::VimMode::Insert; + let theme = Theme::named(ThemeName::Dark); + let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE); + + handle_status_key( + &Event::Key(key), + &key, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &preloaded, + &mut textarea, + &mut vim, + &theme, + None, + || Ok(()), + ) + .unwrap(); + + assert_eq!(textarea.lines(), &["/".to_string()]); + assert!(!state.transcript_search.open); + } + #[test] fn compacting_status_keeps_running_interrupt_shortcut() { let (action_tx, action_rx) = mpsc::unbounded(); @@ -213,6 +956,56 @@ mod tests { assert!(matches!(action_rx.try_recv(), Ok(UserAction::Interrupt))); } + #[test] + fn compacting_keys_clear_only_pending_vim_command_state() { + for (code, modifiers) in [ + (KeyCode::Esc, KeyModifiers::NONE), + (KeyCode::Up, KeyModifiers::NONE), + (KeyCode::Char('z'), KeyModifiers::NONE), + ] { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx.clone(), + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + state.set_status(AppStatus::Compacting); + let mut config = config(); + config.vim_mode = true; + let shared = Arc::new(Mutex::new(config.clone())); + let operation = TestOperationInterrupt::default(); + let theme = Theme::named(ThemeName::Dark); + let mut vim = VimState::new(true); + vim.seed_pending_count_for_test(); + vim.set_named_register_for_test(0, "saved"); + vim.set_repeat_for_test(); + let mut textarea = TextArea::from(["draft"]); + + press_status_key( + code, + modifiers, + &mut state, + &mut config, + &shared, + &action_tx, + &operation, + &mut textarea, + &mut vim, + &theme, + ); + + assert!(!vim.has_pending_command_for_test(), "{code:?}"); + assert_eq!( + vim.named_register_for_test(0), + Some(("saved", false)), + "{code:?}" + ); + assert!(vim.has_repeat_for_test(), "{code:?}"); + assert_eq!(textarea.lines(), &["draft".to_string()], "{code:?}"); + } + } + #[test] fn esc_interrupts_running_without_exiting_or_marking_idle() { let (action_tx, action_rx) = mpsc::unbounded(); diff --git a/crates/orca-tui/src/streaming_markdown.rs b/crates/orca-tui/src/streaming_markdown.rs new file mode 100644 index 00000000..ddf0a94c --- /dev/null +++ b/crates/orca-tui/src/streaming_markdown.rs @@ -0,0 +1,617 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum StreamingMarkdownAction { + UpdateTail(String), + FreezeTail { text: String, trailing_blank: bool }, + AppendFrozen { text: String, trailing_blank: bool }, + ClearTail, + FinishTail(String), +} + +#[derive(Default)] +pub(crate) struct StreamingMarkdownAssembler { + partial_line: String, + current_block: String, + pipe_candidate: Option, + active_table: Option, + fence: Option, + finished: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct FenceState { + marker: char, + run_len: usize, +} + +impl StreamingMarkdownAssembler { + pub(crate) fn push(&mut self, text: &str) -> Vec { + if self.finished || text.is_empty() { + return Vec::new(); + } + self.partial_line.push_str(text); + let Some(last_newline) = self.partial_line.rfind('\n') else { + return Vec::new(); + }; + let complete = self.partial_line[..=last_newline].to_owned(); + self.partial_line.drain(..=last_newline); + let mut actions = Vec::new(); + let mut current_block_changed = false; + for line in complete.split_inclusive('\n') { + self.process_complete_line(line, &mut actions, &mut current_block_changed); + } + if current_block_changed && !self.current_block.is_empty() { + actions.push(StreamingMarkdownAction::UpdateTail( + self.current_block.clone(), + )); + } + actions + } + + pub(crate) fn finish(&mut self) -> Vec { + if self.finished { + return Vec::new(); + } + self.finished = true; + if let Some(mut table) = self.active_table.take() { + let partial_line = std::mem::take(&mut self.partial_line); + if table_row(&partial_line) { + table.push_str(&partial_line); + } + let mut actions = vec![StreamingMarkdownAction::AppendFrozen { + text: table, + trailing_blank: false, + }]; + if !partial_line.is_empty() && !table_row(&partial_line) { + actions.push(StreamingMarkdownAction::FinishTail(partial_line)); + } + return actions; + } + let mut hidden_suffix = self.pipe_candidate.take().unwrap_or_default(); + hidden_suffix.push_str(&self.partial_line); + self.partial_line.clear(); + if self.current_block.is_empty() && hidden_suffix.is_empty() { + return Vec::new(); + } + self.current_block.clear(); + vec![StreamingMarkdownAction::FinishTail(hidden_suffix)] + } + + #[cfg(test)] + fn held_text_for_test(&self) -> String { + let mut held = self + .active_table + .as_ref() + .or(self.pipe_candidate.as_ref()) + .cloned() + .unwrap_or_default(); + held.push_str(&self.partial_line); + held + } + + fn process_complete_line( + &mut self, + line: &str, + actions: &mut Vec, + current_block_changed: &mut bool, + ) { + if let Some(table) = self.active_table.as_mut() { + if line_is_blank(line) { + table.push_str(line); + self.release_table(actions, true); + return; + } + if table_row(line) { + table.push_str(line); + return; + } + self.release_table(actions, false); + } + + if let Some(candidate) = self.pipe_candidate.take() { + if table_delimiter(line) { + if !self.current_block.is_empty() { + self.freeze_current_block(actions, false); + *current_block_changed = false; + } + let mut table = candidate; + table.push_str(line); + self.active_table = Some(table); + return; + } + self.current_block.push_str(&candidate); + *current_block_changed = true; + } + + if let Some(fence) = self.fence { + self.current_block.push_str(line); + *current_block_changed = true; + if fence_closes(line, fence) { + self.fence = None; + self.freeze_current_block(actions, false); + *current_block_changed = false; + } + return; + } + + if let Some(fence) = fence_open(line) { + if !self.current_block.is_empty() { + self.freeze_current_block(actions, false); + *current_block_changed = false; + } + self.current_block.push_str(line); + *current_block_changed = true; + self.fence = Some(fence); + } else if plausible_table_header(line) { + self.pipe_candidate = Some(line.to_owned()); + } else { + let had_content = !self.current_block.trim().is_empty(); + self.current_block.push_str(line); + *current_block_changed = true; + if line_is_blank(line) && had_content { + self.freeze_current_block(actions, true); + *current_block_changed = false; + } + } + } + + fn freeze_current_block( + &mut self, + actions: &mut Vec, + trailing_blank: bool, + ) { + let text = std::mem::take(&mut self.current_block); + actions.push(StreamingMarkdownAction::UpdateTail(text.clone())); + actions.push(StreamingMarkdownAction::FreezeTail { + text, + trailing_blank, + }); + actions.push(StreamingMarkdownAction::ClearTail); + } + + fn release_table(&mut self, actions: &mut Vec, trailing_blank: bool) { + if let Some(text) = self.active_table.take() { + actions.push(StreamingMarkdownAction::AppendFrozen { + text, + trailing_blank, + }); + } + } +} + +fn fence_open(line: &str) -> Option { + let content = line.strip_suffix('\n').unwrap_or(line); + let trimmed = content.trim_start_matches(' '); + if content.len() - trimmed.len() > 3 { + return None; + } + let marker = trimmed.chars().next()?; + if marker != '`' && marker != '~' { + return None; + } + let run_len = trimmed + .chars() + .take_while(|character| *character == marker) + .count(); + (run_len >= 3).then_some(FenceState { marker, run_len }) +} + +fn fence_closes(line: &str, fence: FenceState) -> bool { + let content = line.strip_suffix('\n').unwrap_or(line); + let trimmed = content.trim_start_matches(' '); + if content.len() - trimmed.len() > 3 { + return false; + } + let run_len = trimmed + .chars() + .take_while(|character| *character == fence.marker) + .count(); + run_len >= fence.run_len && trimmed[run_len..].trim().is_empty() +} + +fn line_is_blank(line: &str) -> bool { + line.trim().is_empty() +} + +fn unescaped_pipe_cells(line: &str) -> Option> { + let content = line.strip_suffix('\n').unwrap_or(line); + let mut separators = Vec::new(); + let mut preceding_backslashes = 0; + for (index, character) in content.char_indices() { + if character == '\\' { + preceding_backslashes += 1; + continue; + } + if character == '|' && preceding_backslashes % 2 == 0 { + separators.push(index); + } + preceding_backslashes = 0; + } + if separators.is_empty() { + return None; + } + + let mut cells = Vec::with_capacity(separators.len() + 1); + let mut start = 0; + for separator in separators { + cells.push(&content[start..separator]); + start = separator + 1; + } + cells.push(&content[start..]); + if cells.first().is_some_and(|cell| cell.trim().is_empty()) { + cells.remove(0); + } + if cells.last().is_some_and(|cell| cell.trim().is_empty()) { + cells.pop(); + } + Some(cells) +} + +fn plausible_table_header(line: &str) -> bool { + let Some(cells) = unescaped_pipe_cells(line) else { + return false; + }; + !cells.is_empty() && cells.iter().any(|cell| !cell.trim().is_empty()) && !table_delimiter(line) +} + +fn table_delimiter(line: &str) -> bool { + let Some(cells) = unescaped_pipe_cells(line) else { + return false; + }; + !cells.is_empty() + && cells.iter().all(|cell| { + let trimmed = cell.trim(); + let without_left = trimmed.strip_prefix(':').unwrap_or(trimmed); + let without_colons = without_left.strip_suffix(':').unwrap_or(without_left); + !without_colons.is_empty() && without_colons.chars().all(|character| character == '-') + }) +} + +fn table_row(line: &str) -> bool { + unescaped_pipe_cells(line) + .is_some_and(|cells| !cells.is_empty() && cells.iter().any(|cell| !cell.trim().is_empty())) +} + +#[cfg(test)] +mod tests { + use super::{StreamingMarkdownAction, StreamingMarkdownAssembler}; + + fn reconstructed_action_text(actions: &[StreamingMarkdownAction]) -> String { + let mut frozen = String::new(); + let mut tail = String::new(); + for action in actions { + match action { + StreamingMarkdownAction::UpdateTail(text) => tail.clone_from(text), + StreamingMarkdownAction::FreezeTail { text, .. } => { + frozen.push_str(text); + tail.clear(); + } + StreamingMarkdownAction::AppendFrozen { text, .. } => frozen.push_str(text), + StreamingMarkdownAction::ClearTail => tail.clear(), + StreamingMarkdownAction::FinishTail(text) => tail.push_str(text), + } + } + frozen.push_str(&tail); + frozen + } + + #[test] + fn partial_source_line_stays_hidden_until_newline_or_finish() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert!(assembler.push("hello").is_empty()); + assert_eq!(assembler.held_text_for_test(), "hello"); + assert_eq!( + assembler.push(" world\n"), + vec![StreamingMarkdownAction::UpdateTail( + "hello world\n".to_string() + )] + ); + assert_eq!(assembler.held_text_for_test(), ""); + + assert!(assembler.push("final").is_empty()); + assert_eq!( + assembler.finish(), + vec![StreamingMarkdownAction::FinishTail("final".to_string())] + ); + assert!(assembler.finish().is_empty()); + } + + #[test] + fn newline_gate_reconstructs_cjk_emoji_and_combining_text_exactly() { + let mut assembler = StreamingMarkdownAssembler::default(); + let input = ["中", "文👍🏽e\u{301}", "\n尾", "行"]; + let mut actions = Vec::new(); + for piece in input { + actions.extend(assembler.push(piece)); + } + actions.extend(assembler.finish()); + assert_eq!(reconstructed_action_text(&actions), "中文👍🏽e\u{301}\n尾行"); + } + + #[test] + fn blank_line_freezes_the_visible_tail_and_starts_a_fresh_block() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert_eq!( + assembler.push("first paragraph\n\n"), + vec![ + StreamingMarkdownAction::UpdateTail("first paragraph\n\n".to_string()), + StreamingMarkdownAction::FreezeTail { + text: "first paragraph\n\n".to_string(), + trailing_blank: true, + }, + StreamingMarkdownAction::ClearTail, + ] + ); + assert_eq!( + assembler.push("second paragraph\n"), + vec![StreamingMarkdownAction::UpdateTail( + "second paragraph\n".to_string() + )] + ); + } + + #[test] + fn consecutive_blank_lines_preserve_source_with_one_display_blank() { + let mut assembler = StreamingMarkdownAssembler::default(); + let actions = assembler.push("paragraph\n\n\n"); + assert_eq!( + actions, + vec![ + StreamingMarkdownAction::UpdateTail("paragraph\n\n".to_string()), + StreamingMarkdownAction::FreezeTail { + text: "paragraph\n\n".to_string(), + trailing_blank: true, + }, + StreamingMarkdownAction::ClearTail, + StreamingMarkdownAction::UpdateTail("\n".to_string()), + ] + ); + assert_eq!(reconstructed_action_text(&actions), "paragraph\n\n\n"); + } + + #[test] + fn leading_blank_line_stays_with_following_tail_content() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert_eq!( + assembler.push("\nOutro"), + vec![StreamingMarkdownAction::UpdateTail("\n".to_string())] + ); + assert_eq!( + assembler.finish(), + vec![StreamingMarkdownAction::FinishTail("Outro".to_string())] + ); + } + + #[test] + fn fenced_block_freezes_only_after_matching_close() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert_eq!( + assembler.push("before\n\n```rust\nfn main() {\n"), + vec![ + StreamingMarkdownAction::UpdateTail("before\n\n".to_string()), + StreamingMarkdownAction::FreezeTail { + text: "before\n\n".to_string(), + trailing_blank: true, + }, + StreamingMarkdownAction::ClearTail, + StreamingMarkdownAction::UpdateTail("```rust\nfn main() {\n".to_string()), + ] + ); + assert_eq!( + assembler.push("}\n```\n"), + vec![ + StreamingMarkdownAction::UpdateTail("```rust\nfn main() {\n}\n```\n".to_string()), + StreamingMarkdownAction::FreezeTail { + text: "```rust\nfn main() {\n}\n```\n".to_string(), + trailing_blank: false, + }, + StreamingMarkdownAction::ClearTail, + ] + ); + } + + #[test] + fn fence_opener_freezes_preceding_paragraph_without_blank_line() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert_eq!( + assembler.push("before\n```rust\ncode\n"), + vec![ + StreamingMarkdownAction::UpdateTail("before\n".to_string()), + StreamingMarkdownAction::FreezeTail { + text: "before\n".to_string(), + trailing_blank: false, + }, + StreamingMarkdownAction::ClearTail, + StreamingMarkdownAction::UpdateTail("```rust\ncode\n".to_string()), + ] + ); + } + + #[test] + fn fences_require_matching_marker_and_sufficient_closing_run() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert_eq!( + assembler.push(" ~~~~text\n~~~\n````\n~~~~\n"), + vec![ + StreamingMarkdownAction::UpdateTail(" ~~~~text\n~~~\n````\n~~~~\n".to_string()), + StreamingMarkdownAction::FreezeTail { + text: " ~~~~text\n~~~\n````\n~~~~\n".to_string(), + trailing_blank: false, + }, + StreamingMarkdownAction::ClearTail, + ] + ); + } + + #[test] + fn unfinished_fence_is_emitted_by_finish() { + let mut assembler = StreamingMarkdownAssembler::default(); + let mut actions = assembler.push("```rust\nlet value = 1;\n"); + actions.extend(assembler.push("unfinished")); + actions.extend(assembler.finish()); + assert_eq!( + reconstructed_action_text(&actions), + "```rust\nlet value = 1;\nunfinished" + ); + } + + #[test] + fn action_stream_and_held_suffix_reconstruct_every_input_prefix() { + let fixtures = [ + vec!["# Head", "ing\n\n- one\n", "- two"], + vec!["前文\n\n", "~~~text\n中", "文\n~~~\n", "尾"], + vec!["e\u{301}", "moji 👍🏽\n", "\n````\n", "code\n```", "`\n"], + ]; + + for pieces in fixtures { + let mut assembler = StreamingMarkdownAssembler::default(); + let mut actions = Vec::new(); + let mut prefix = String::new(); + for piece in pieces { + prefix.push_str(piece); + actions.extend(assembler.push(piece)); + let mut reconstructed = reconstructed_action_text(&actions); + reconstructed.push_str(&assembler.held_text_for_test()); + assert_eq!(reconstructed, prefix); + } + actions.extend(assembler.finish()); + assert_eq!(reconstructed_action_text(&actions), prefix); + } + } + + #[test] + fn pipe_header_candidate_is_hidden_until_confirmed_or_rejected() { + let mut confirmed = StreamingMarkdownAssembler::default(); + assert!(confirmed.push("| Name | Value |\n").is_empty()); + assert_eq!(confirmed.held_text_for_test(), "| Name | Value |\n"); + assert!(confirmed.push("|---|---|\n").is_empty()); + assert_eq!( + confirmed.held_text_for_test(), + "| Name | Value |\n|---|---|\n" + ); + + let mut rejected = StreamingMarkdownAssembler::default(); + assert!(rejected.push("A | B\n").is_empty()); + assert_eq!( + rejected.push("ordinary next line\n"), + vec![StreamingMarkdownAction::UpdateTail( + "A | B\nordinary next line\n".to_string() + )] + ); + } + + #[test] + fn table_detection_handles_escaped_pipes_empty_cells_and_alignment() { + let mut escaped = StreamingMarkdownAssembler::default(); + assert_eq!( + escaped.push( + r"escaped \| pipe +ordinary +" + ), + vec![StreamingMarkdownAction::UpdateTail( + r"escaped \| pipe +ordinary +" + .to_string() + )] + ); + + let mut empty = StreamingMarkdownAssembler::default(); + assert_eq!( + empty.push("| | |\n|---|---|\n"), + vec![StreamingMarkdownAction::UpdateTail( + "| | |\n|---|---|\n".to_string() + )] + ); + + let mut aligned = StreamingMarkdownAssembler::default(); + assert!( + aligned + .push("| Left | Center | Right |\n|:---|:---:|---:|\n") + .is_empty() + ); + } + + #[test] + fn single_column_table_with_structural_pipes_is_held_and_released() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert!(assembler.push("| Name |\n|---|\n| A |\n").is_empty()); + assert_eq!( + assembler.finish(), + vec![StreamingMarkdownAction::AppendFrozen { + text: "| Name |\n|---|\n| A |\n".to_string(), + trailing_blank: false, + }] + ); + } + + #[test] + fn confirmed_table_remains_hidden_until_boundary_then_emits_once() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert!(assembler.push("| Name | Value |\n|---|---|\n").is_empty()); + assert!(assembler.push("| A | 1 |\n").is_empty()); + assert_eq!( + assembler.push("\n"), + vec![StreamingMarkdownAction::AppendFrozen { + text: "| Name | Value |\n|---|---|\n| A | 1 |\n\n".to_string(), + trailing_blank: true, + }] + ); + assert!(assembler.finish().is_empty()); + } + + #[test] + fn table_releases_before_non_table_terminator_and_at_finish() { + let mut terminated = StreamingMarkdownAssembler::default(); + assert!( + terminated + .push("| Name | Value |\n|---|---|\n| A | 1 |\n") + .is_empty() + ); + assert_eq!( + terminated.push("after\n"), + vec![ + StreamingMarkdownAction::AppendFrozen { + text: "| Name | Value |\n|---|---|\n| A | 1 |\n".to_string(), + trailing_blank: false, + }, + StreamingMarkdownAction::UpdateTail("after\n".to_string()), + ] + ); + + let mut finished = StreamingMarkdownAssembler::default(); + assert!( + finished + .push("| Name | Value |\n|---|---|\n| A | 1 |\n") + .is_empty() + ); + assert_eq!( + finished.finish(), + vec![StreamingMarkdownAction::AppendFrozen { + text: "| Name | Value |\n|---|---|\n| A | 1 |\n".to_string(), + trailing_blank: false, + }] + ); + } + + #[test] + fn table_finish_keeps_partial_non_table_tail_separate() { + let mut assembler = StreamingMarkdownAssembler::default(); + assert!( + assembler + .push("| Name | Value |\n|---|---|\n| A | 1 |\n") + .is_empty() + ); + assert!(assembler.push("after").is_empty()); + assert_eq!( + assembler.finish(), + vec![ + StreamingMarkdownAction::AppendFrozen { + text: "| Name | Value |\n|---|---|\n| A | 1 |\n".to_string(), + trailing_blank: false, + }, + StreamingMarkdownAction::FinishTail("after".to_string()), + ] + ); + } +} diff --git a/crates/orca-tui/src/submitted_turn.rs b/crates/orca-tui/src/submitted_turn.rs index 8fbeb9b6..13da2853 100644 --- a/crates/orca-tui/src/submitted_turn.rs +++ b/crates/orca-tui/src/submitted_turn.rs @@ -34,6 +34,7 @@ impl SubmittedTurnPresentation { pub(crate) struct SubmittedTurn { kind: SubmittedTurnKind, presentation: SubmittedTurnPresentation, + queued_id: Option, } impl SubmittedTurn { @@ -44,6 +45,7 @@ impl SubmittedTurn { prompt, }, presentation: SubmittedTurnPresentation::user(), + queued_id: None, } } @@ -51,6 +53,19 @@ impl SubmittedTurn { Self { kind: SubmittedTurnKind::User { prompt, bindings }, presentation: SubmittedTurnPresentation::user(), + queued_id: None, + } + } + + pub(crate) fn queued_user_with_mentions( + id: u64, + prompt: String, + bindings: mentions::MentionBindings, + ) -> Self { + Self { + kind: SubmittedTurnKind::User { prompt, bindings }, + presentation: SubmittedTurnPresentation::user(), + queued_id: Some(id), } } @@ -59,9 +74,14 @@ impl SubmittedTurn { Self { kind: SubmittedTurnKind::WorkflowNotification(notification), presentation: SubmittedTurnPresentation::workflow_notification(&id), + queued_id: None, } } + pub(crate) fn queued_id(&self) -> Option { + self.queued_id + } + pub(crate) fn prompt(&self) -> &str { match &self.kind { SubmittedTurnKind::User { prompt, .. } => prompt, diff --git a/crates/orca-tui/src/syntax_highlight.rs b/crates/orca-tui/src/syntax_highlight.rs new file mode 100644 index 00000000..9f922279 --- /dev/null +++ b/crates/orca-tui/src/syntax_highlight.rs @@ -0,0 +1,510 @@ +use std::path::Path; +use std::sync::OnceLock; + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use syntect::easy::HighlightLines; +use syntect::highlighting::{FontStyle, Theme}; +use syntect::parsing::{SyntaxReference, SyntaxSet}; +use syntect::util::LinesWithEndings; +use two_face::theme::{EmbeddedLazyThemeSet, EmbeddedThemeName}; + +use crate::terminal_capabilities::TerminalColorLevel; + +pub(crate) const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; +pub(crate) const MAX_HIGHLIGHT_LINES: usize = 10_000; +pub(crate) const MAX_HIGHLIGHT_LINE_BYTES: usize = 4 * 1024; + +static SYNTAX_SET: OnceLock = OnceLock::new(); +static THEME_SET: OnceLock = OnceLock::new(); + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum SyntaxTheme { + OneHalfDark, + OneHalfLight, + SolarizedDark, + CatppuccinMocha, +} + +impl SyntaxTheme { + pub(crate) const fn revision(self) -> u64 { + match self { + Self::OneHalfDark => 1, + Self::OneHalfLight => 2, + Self::SolarizedDark => 3, + Self::CatppuccinMocha => 4, + } + } + + fn embedded(self) -> EmbeddedThemeName { + match self { + Self::OneHalfDark => EmbeddedThemeName::OneHalfDark, + Self::OneHalfLight => EmbeddedThemeName::OneHalfLight, + Self::SolarizedDark => EmbeddedThemeName::SolarizedDark, + Self::CatppuccinMocha => EmbeddedThemeName::CatppuccinMocha, + } + } + + fn theme(self) -> &'static Theme { + theme_set().get(self.embedded()) + } +} + +pub(crate) type StyledSourceLine = Vec>; + +pub(crate) fn content_within_limits(content: &str) -> bool { + if content.len() > MAX_HIGHLIGHT_BYTES { + return false; + } + + for (line_index, line) in content.lines().enumerate() { + if line_index + 1 > MAX_HIGHLIGHT_LINES || line.len() > MAX_HIGHLIGHT_LINE_BYTES { + return false; + } + } + + true +} + +fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) +} + +fn theme_set() -> &'static EmbeddedLazyThemeSet { + THEME_SET.get_or_init(two_face::theme::extra) +} + +fn first_info_token(info: &str) -> &str { + info.split([',', ' ', '\t']).next().unwrap_or_default() +} + +fn find_syntax(token: &str) -> Option<&'static SyntaxReference> { + let raw = first_info_token(token); + if raw.is_empty() { + return None; + } + + let normalized = raw.to_ascii_lowercase(); + let patched = match normalized.as_str() { + "csharp" | "c-sharp" => "c#", + "golang" => "go", + "python3" => "python", + "shell" => "bash", + _ => raw, + }; + let set = syntax_set(); + + set.find_syntax_by_token(patched) + .or_else(|| set.find_syntax_by_name(patched)) + .or_else(|| { + set.syntaxes() + .iter() + .find(|syntax| syntax.name.eq_ignore_ascii_case(patched)) + }) + .or_else(|| set.find_syntax_by_extension(patched)) +} + +fn to_ratatui_style(style: syntect::highlighting::Style, color_level: TerminalColorLevel) -> Style { + let mut output = Style::default().fg(Color::Rgb( + style.foreground.r, + style.foreground.g, + style.foreground.b, + )); + if style.font_style.contains(FontStyle::BOLD) { + output = output.add_modifier(Modifier::BOLD); + } + color_level.adapt_style(output) +} + +fn structural_line_ending_len(source_line: &str) -> usize { + if source_line.ends_with("\r\n") { + 2 + } else if source_line.ends_with('\n') { + 1 + } else { + 0 + } +} + +fn to_spans( + ranges: Vec<(syntect::highlighting::Style, &str)>, + structural_ending_len: usize, + color_level: TerminalColorLevel, +) -> StyledSourceLine { + let content_len = ranges + .iter() + .map(|(_, segment)| segment.len()) + .sum::() + - structural_ending_len; + let mut spans = Vec::new(); + let mut consumed = 0; + for (style, segment) in ranges { + let retained_len = content_len.saturating_sub(consumed).min(segment.len()); + let text = &segment[..retained_len]; + if !text.is_empty() { + spans.push(Span::styled( + text.to_owned(), + to_ratatui_style(style, color_level), + )); + } + consumed += segment.len(); + } + if spans.is_empty() { + spans.push(Span::raw(String::new())); + } + spans +} + +pub(crate) fn highlight_code( + code: &str, + language: &str, + theme: SyntaxTheme, + color_level: TerminalColorLevel, +) -> Option> { + if code.is_empty() || !content_within_limits(code) { + return None; + } + + let syntax = find_syntax(language)?; + let mut highlighter = HighlightLines::new(syntax, theme.theme()); + LinesWithEndings::from(code) + .map(|source_line| { + let structural_ending_len = structural_line_ending_len(source_line); + highlighter + .highlight_line(source_line, syntax_set()) + .ok() + .map(|ranges| to_spans(ranges, structural_ending_len, color_level)) + }) + .collect() +} + +pub(crate) struct LineHighlighter { + inner: HighlightLines<'static>, + color_level: TerminalColorLevel, +} + +impl LineHighlighter { + pub(crate) fn highlight_line(&mut self, text: &str) -> Option { + let source_line = format!("{text}\n"); + let ranges = self.inner.highlight_line(&source_line, syntax_set()).ok()?; + Some(to_spans(ranges, 1, self.color_level)) + } +} + +pub(crate) fn highlighter_for_path( + path: &Path, + theme: SyntaxTheme, + color_level: TerminalColorLevel, +) -> Option { + let syntax = find_syntax_for_path(path)?; + + Some(LineHighlighter { + inner: HighlightLines::new(syntax, theme.theme()), + color_level, + }) +} + +fn find_syntax_for_path(path: &Path) -> Option<&'static SyntaxReference> { + path.file_name() + .and_then(|file_name| file_name.to_str()) + .and_then(find_syntax) + .or_else(|| { + path.extension() + .and_then(|extension| extension.to_str()) + .and_then(find_syntax) + }) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::path::Path; + + use ratatui::style::{Color, Modifier}; + use ratatui::text::Span; + + use super::{ + MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINE_BYTES, MAX_HIGHLIGHT_LINES, SyntaxTheme, + content_within_limits, find_syntax_for_path, highlight_code, highlighter_for_path, + }; + use crate::terminal_capabilities::TerminalColorLevel; + + fn distinct_foregrounds(lines: &[Vec>]) -> usize { + lines + .iter() + .flatten() + .filter_map(|span| span.style.fg) + .collect::>() + .len() + } + + fn color_fits(level: TerminalColorLevel, color: Option) -> bool { + match level { + TerminalColorLevel::TrueColor => true, + TerminalColorLevel::Ansi256 => !matches!(color, Some(Color::Rgb(..))), + TerminalColorLevel::Ansi16 => { + !matches!(color, Some(Color::Rgb(..) | Color::Indexed(_))) + } + TerminalColorLevel::Monochrome => color.is_none() || color == Some(Color::Reset), + } + } + + #[test] + fn highlighted_styles_obey_terminal_color_level() { + for level in [ + TerminalColorLevel::TrueColor, + TerminalColorLevel::Ansi256, + TerminalColorLevel::Ansi16, + TerminalColorLevel::Monochrome, + ] { + let lines = highlight_code( + "pub struct Item;\n", + "rust", + SyntaxTheme::OneHalfDark, + level, + ) + .unwrap(); + assert!( + lines + .iter() + .flatten() + .all(|span| color_fits(level, span.style.fg)) + ); + } + } + + #[test] + fn rust_preserves_source_and_uses_multiple_foregrounds_without_backgrounds() { + let lines = highlight_code( + "fn main() { let answer = \"forty two\"; }\n", + "rust", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .expect("Rust syntax"); + + assert_eq!( + lines + .iter() + .flatten() + .map(|span| span.content.as_ref()) + .collect::(), + "fn main() { let answer = \"forty two\"; }" + ); + assert!(distinct_foregrounds(&lines) >= 2); + assert!(lines.iter().flatten().all(|span| span.style.bg.is_none())); + } + + #[test] + fn fence_metadata_and_aliases_resolve() { + assert!( + highlight_code( + "let value = 1;\n", + "rust,no_run", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .is_some() + ); + assert!( + highlight_code( + "print('value')\n", + "python3", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .is_some() + ); + assert!( + highlight_code( + "echo hi\n", + "shell", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .is_some() + ); + assert!( + highlight_code( + "value\n", + "not-a-real-language", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn syntax_theme_revisions_are_stable_and_distinct() { + assert_eq!(SyntaxTheme::OneHalfDark.revision(), 1); + assert_eq!(SyntaxTheme::OneHalfLight.revision(), 2); + assert_eq!(SyntaxTheme::SolarizedDark.revision(), 3); + assert_eq!(SyntaxTheme::CatppuccinMocha.revision(), 4); + } + + #[test] + fn strict_limits_reject_only_values_above_each_ceiling() { + let exact_bytes = format!("{}\n", "x".repeat(MAX_HIGHLIGHT_LINE_BYTES - 1)) + .repeat(MAX_HIGHLIGHT_BYTES / MAX_HIGHLIGHT_LINE_BYTES); + assert_eq!(exact_bytes.len(), MAX_HIGHLIGHT_BYTES); + assert!(content_within_limits(&exact_bytes)); + let mut too_many_bytes = exact_bytes; + too_many_bytes.push('x'); + assert_eq!(too_many_bytes.len(), MAX_HIGHLIGHT_BYTES + 1); + assert!(!content_within_limits(&too_many_bytes)); + + let exact_lines = "x\n".repeat(MAX_HIGHLIGHT_LINES); + assert_eq!(exact_lines.lines().count(), MAX_HIGHLIGHT_LINES); + assert!(content_within_limits(&exact_lines)); + let mut too_many_lines = exact_lines; + too_many_lines.push('x'); + assert_eq!(too_many_lines.lines().count(), MAX_HIGHLIGHT_LINES + 1); + assert!(!content_within_limits(&too_many_lines)); + + assert!(content_within_limits(&"x".repeat(MAX_HIGHLIGHT_LINE_BYTES))); + assert!(!content_within_limits( + &"x".repeat(MAX_HIGHLIGHT_LINE_BYTES + 1) + )); + } + + #[test] + fn highlighting_preserves_logical_blank_lines() { + let lines = highlight_code( + "fn first() {}\n\nfn second() {}", + "rust", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .expect("Rust syntax"); + + assert_eq!(lines.len(), 3); + assert_eq!( + lines[1] + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "" + ); + } + + #[test] + fn highlighting_strips_only_structural_line_endings() { + let crlf = highlight_code( + "let first = 1;\r\n", + "rust", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .expect("Rust syntax"); + let literal_cr = highlight_code( + "let second = 2;\r", + "rust", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .expect("Rust syntax"); + + assert_eq!( + crlf.iter() + .flatten() + .map(|span| span.content.as_ref()) + .collect::(), + "let first = 1;" + ); + assert_eq!( + literal_cr + .iter() + .flatten() + .map(|span| span.content.as_ref()) + .collect::(), + "let second = 2;\r" + ); + } + + #[test] + fn path_highlighter_preserves_multiline_parser_state() { + let mut continued = highlighter_for_path( + Path::new("src/main.rs"), + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .expect("Rust path"); + continued + .highlight_line("/* comment starts") + .expect("comment start"); + let continued_second = continued + .highlight_line("still comment */ let value = 1;") + .expect("continued second line"); + + let mut fresh = highlighter_for_path( + Path::new("src/main.rs"), + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .expect("fresh Rust path"); + let fresh_second = fresh + .highlight_line("still comment */ let value = 1;") + .expect("fresh second line"); + + assert_eq!( + continued_second + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "still comment */ let value = 1;" + ); + assert_eq!( + fresh_second + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + "still comment */ let value = 1;" + ); + assert_ne!(continued_second, fresh_second); + assert!( + highlighter_for_path( + Path::new("src/file.not-a-real-language"), + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .is_none() + ); + } + + #[test] + fn path_lookup_prefers_complete_filename_before_extension() { + assert_eq!( + find_syntax_for_path(Path::new("CMakeLists.txt")) + .expect("CMakeLists syntax") + .name, + "CMake" + ); + assert_eq!( + find_syntax_for_path(Path::new("src/main.rs")) + .expect("Rust syntax") + .name, + "Rust" + ); + } + + #[test] + fn converted_styles_use_foreground_and_optional_bold_only() { + let lines = highlight_code( + "pub struct Item;\n", + "rust", + SyntaxTheme::OneHalfDark, + TerminalColorLevel::TrueColor, + ) + .expect("Rust syntax"); + + assert!(lines.iter().flatten().all(|span| { + matches!(span.style.fg, Some(Color::Rgb(_, _, _))) + && span.style.bg.is_none() + && !span.style.add_modifier.contains(Modifier::ITALIC) + && !span.style.add_modifier.contains(Modifier::UNDERLINED) + && (span.style.add_modifier.is_empty() || span.style.add_modifier == Modifier::BOLD) + && span.style.sub_modifier.is_empty() + })); + } +} diff --git a/crates/orca-tui/src/terminal_capabilities.rs b/crates/orca-tui/src/terminal_capabilities.rs new file mode 100644 index 00000000..ce2fcfac --- /dev/null +++ b/crates/orca-tui/src/terminal_capabilities.rs @@ -0,0 +1,410 @@ +#![cfg_attr(not(test), allow(dead_code))] + +use orca_core::config::ThemeName; +use ratatui::style::{Color, Style}; + +use crate::syntax_highlight::SyntaxTheme; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct TerminalProfile { + pub(crate) background: TerminalBackground, + pub(crate) color_level: TerminalColorLevel, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TerminalBackground { + Dark, + Light, + Unknown, +} + +impl TerminalBackground { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Dark => "dark", + Self::Light => "light", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TerminalColorLevel { + TrueColor, + Ansi256, + Ansi16, + Monochrome, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ColorSupportFacts { + has_basic: bool, + has_256: bool, + has_16m: bool, +} + +pub(crate) fn terminal_background_from_rgb( + requested: ThemeName, + background: Option, +) -> TerminalBackground { + if requested != ThemeName::Auto { + return TerminalBackground::Unknown; + } + let Some(background) = background else { + return TerminalBackground::Unknown; + }; + if perceived_lightness(background) > 0.5 { + TerminalBackground::Light + } else { + TerminalBackground::Dark + } +} + +fn perceived_lightness(color: qwertty::Rgb) -> f32 { + let luminance = 0.2126 * linear_channel(color.red()) + + 0.7152 * linear_channel(color.green()) + + 0.0722 * linear_channel(color.blue()); + let lightness = if luminance <= 216.0 / 24389.0 { + luminance * (24389.0 / 27.0) + } else { + luminance.cbrt() * 116.0 - 16.0 + }; + lightness / 100.0 +} + +fn linear_channel(channel: u8) -> f32 { + let channel = f32::from(channel) / f32::from(u8::MAX); + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } +} + +pub(crate) fn system_color_level() -> TerminalColorLevel { + color_level_from_facts( + supports_color::on(supports_color::Stream::Stdout).map(|level| ColorSupportFacts { + has_basic: level.has_basic, + has_256: level.has_256, + has_16m: level.has_16m, + }), + ) +} + +pub(crate) const fn resolve_base_theme( + requested: ThemeName, + background: TerminalBackground, +) -> ThemeName { + match (requested, background) { + (ThemeName::Auto, TerminalBackground::Light) => ThemeName::Light, + (ThemeName::Auto, TerminalBackground::Dark | TerminalBackground::Unknown) => { + ThemeName::Dark + } + (explicit, _) => explicit, + } +} + +const fn color_level_from_facts(facts: Option) -> TerminalColorLevel { + match facts { + Some(ColorSupportFacts { has_16m: true, .. }) => TerminalColorLevel::TrueColor, + Some(ColorSupportFacts { has_256: true, .. }) => TerminalColorLevel::Ansi256, + Some(ColorSupportFacts { + has_basic: true, .. + }) => TerminalColorLevel::Ansi16, + Some(_) | None => TerminalColorLevel::Monochrome, + } +} + +impl TerminalColorLevel { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::TrueColor => "truecolor", + Self::Ansi256 => "ansi256", + Self::Ansi16 => "ansi16", + Self::Monochrome => "monochrome", + } + } + + pub(crate) const fn revision(self) -> u64 { + match self { + Self::TrueColor => 0, + Self::Ansi256 => 0x100, + Self::Ansi16 => 0x200, + Self::Monochrome => 0x300, + } + } + + pub(crate) fn adapt_color(self, color: Color) -> Color { + match self { + Self::TrueColor => color, + Self::Ansi256 => match color { + Color::Rgb(red, green, blue) => Color::Indexed(nearest_xterm_256(red, green, blue)), + _ => color, + }, + Self::Ansi16 => match color { + Color::Rgb(red, green, blue) => nearest_ansi_16(red, green, blue), + Color::Indexed(index) => { + let (red, green, blue) = xterm_index_rgb(index); + nearest_ansi_16(red, green, blue) + } + _ => color, + }, + Self::Monochrome => match color { + Color::Reset => Color::Reset, + _ => Color::Reset, + }, + } + } + + pub(crate) fn adapt_style(self, style: Style) -> Style { + Style { + fg: style.fg.map(|color| self.adapt_color(color)), + bg: style.bg.map(|color| self.adapt_color(color)), + underline_color: style.underline_color.map(|color| self.adapt_color(color)), + ..style + } + } +} + +pub(crate) const fn syntax_style_revision( + syntax_theme: SyntaxTheme, + color_level: TerminalColorLevel, +) -> u64 { + syntax_theme.revision() + color_level.revision() +} + +const ANSI_16: [(Color, (u8, u8, u8)); 16] = [ + (Color::Black, (0, 0, 0)), + (Color::Red, (205, 0, 0)), + (Color::Green, (0, 205, 0)), + (Color::Yellow, (205, 205, 0)), + (Color::Blue, (0, 0, 238)), + (Color::Magenta, (205, 0, 205)), + (Color::Cyan, (0, 205, 205)), + (Color::Gray, (229, 229, 229)), + (Color::DarkGray, (127, 127, 127)), + (Color::LightRed, (255, 0, 0)), + (Color::LightGreen, (0, 255, 0)), + (Color::LightYellow, (255, 255, 0)), + (Color::LightBlue, (92, 92, 255)), + (Color::LightMagenta, (255, 0, 255)), + (Color::LightCyan, (0, 255, 255)), + (Color::White, (255, 255, 255)), +]; + +const XTERM_CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255]; + +fn nearest_xterm_256(red: u8, green: u8, blue: u8) -> u8 { + let mut nearest = 16; + let mut nearest_distance = i32::MAX; + for index in 16..=255 { + let (candidate_red, candidate_green, candidate_blue) = xterm_index_rgb(index); + let distance = color_distance( + (red, green, blue), + (candidate_red, candidate_green, candidate_blue), + ); + if distance < nearest_distance { + nearest = index; + nearest_distance = distance; + } + } + nearest +} + +fn nearest_ansi_16(red: u8, green: u8, blue: u8) -> Color { + let mut nearest = Color::Black; + let mut nearest_distance = i32::MAX; + for (candidate, rgb) in ANSI_16 { + let distance = color_distance((red, green, blue), rgb); + if distance < nearest_distance { + nearest = candidate; + nearest_distance = distance; + } + } + nearest +} + +fn xterm_index_rgb(index: u8) -> (u8, u8, u8) { + match index { + 0..=15 => ANSI_16[index as usize].1, + 16..=231 => { + let cube_index = index - 16; + ( + XTERM_CUBE_LEVELS[(cube_index / 36) as usize], + XTERM_CUBE_LEVELS[((cube_index % 36) / 6) as usize], + XTERM_CUBE_LEVELS[(cube_index % 6) as usize], + ) + } + 232..=255 => { + let level = 8 + 10 * (index - 232); + (level, level, level) + } + } +} + +fn color_distance(left: (u8, u8, u8), right: (u8, u8, u8)) -> i32 { + let red = i32::from(left.0) - i32::from(right.0); + let green = i32::from(left.1) - i32::from(right.1); + let blue = i32::from(left.2) - i32::from(right.2); + red * red + green * green + blue * blue +} + +#[cfg(test)] +mod tests { + use orca_core::config::ThemeName; + use ratatui::style::{Color, Modifier, Style}; + + use super::{ + ColorSupportFacts, TerminalBackground, TerminalColorLevel, color_level_from_facts, + resolve_base_theme, terminal_background_from_rgb, + }; + + #[test] + fn qwertty_background_rgb_maps_by_perceived_lightness() { + assert_eq!( + terminal_background_from_rgb(ThemeName::Auto, Some(qwertty::Rgb::new(0, 0, 0))), + TerminalBackground::Dark + ); + assert_eq!( + terminal_background_from_rgb(ThemeName::Auto, Some(qwertty::Rgb::new(255, 255, 255))), + TerminalBackground::Light + ); + assert_eq!( + terminal_background_from_rgb(ThemeName::Auto, Some(qwertty::Rgb::new(118, 118, 118))), + TerminalBackground::Dark + ); + assert_eq!( + terminal_background_from_rgb(ThemeName::Auto, None), + TerminalBackground::Unknown + ); + } + + #[test] + fn explicit_theme_ignores_qwertty_background_rgb() { + for requested in [ + ThemeName::Dark, + ThemeName::Light, + ThemeName::Solarized, + ThemeName::Catppuccin, + ] { + assert_eq!( + terminal_background_from_rgb(requested, Some(qwertty::Rgb::new(255, 255, 255))), + TerminalBackground::Unknown + ); + } + } + + #[test] + fn auto_uses_detected_background_and_explicit_themes_ignore_it() { + assert_eq!( + resolve_base_theme(ThemeName::Auto, TerminalBackground::Light), + ThemeName::Light + ); + assert_eq!( + resolve_base_theme(ThemeName::Auto, TerminalBackground::Dark), + ThemeName::Dark + ); + assert_eq!( + resolve_base_theme(ThemeName::Auto, TerminalBackground::Unknown), + ThemeName::Dark + ); + + for explicit in [ + ThemeName::Dark, + ThemeName::Light, + ThemeName::Solarized, + ThemeName::Catppuccin, + ] { + for background in [ + TerminalBackground::Dark, + TerminalBackground::Light, + TerminalBackground::Unknown, + ] { + assert_eq!(resolve_base_theme(explicit, background), explicit); + } + } + } + + #[test] + fn color_support_facts_map_to_exact_levels() { + assert_eq!(color_level_from_facts(None), TerminalColorLevel::Monochrome); + assert_eq!( + color_level_from_facts(Some(ColorSupportFacts { + has_basic: true, + ..Default::default() + })), + TerminalColorLevel::Ansi16 + ); + assert_eq!( + color_level_from_facts(Some(ColorSupportFacts { + has_basic: true, + has_256: true, + ..Default::default() + })), + TerminalColorLevel::Ansi256 + ); + assert_eq!( + color_level_from_facts(Some(ColorSupportFacts { + has_basic: true, + has_256: true, + has_16m: true, + })), + TerminalColorLevel::TrueColor + ); + } + + #[test] + fn rgb_quantization_uses_stable_xterm_palettes() { + assert_eq!( + TerminalColorLevel::Ansi256.adapt_color(Color::Rgb(255, 0, 0)), + Color::Indexed(196) + ); + assert_eq!( + TerminalColorLevel::Ansi256.adapt_color(Color::Rgb(128, 128, 128)), + Color::Indexed(244) + ); + assert_eq!( + TerminalColorLevel::Ansi16.adapt_color(Color::Rgb(255, 0, 0)), + Color::LightRed + ); + assert_eq!( + TerminalColorLevel::Ansi16.adapt_color(Color::Indexed(196)), + Color::LightRed + ); + assert_eq!( + TerminalColorLevel::Ansi16.adapt_color(Color::Rgb(205, 0, 0)), + Color::Red + ); + } + + #[test] + fn monochrome_style_preserves_modifiers_and_resets_colors() { + let style = Style::default() + .fg(Color::Rgb(1, 2, 3)) + .bg(Color::Indexed(42)) + .add_modifier(Modifier::BOLD | Modifier::ITALIC | Modifier::REVERSED); + let adapted = TerminalColorLevel::Monochrome.adapt_style(style); + + assert_eq!(adapted.fg, Some(Color::Reset)); + assert_eq!(adapted.bg, Some(Color::Reset)); + assert_eq!(adapted.add_modifier, style.add_modifier); + } + + #[test] + fn style_underline_color_obeys_terminal_level_and_preserves_modifiers() { + let style = Style::default() + .underline_color(Color::Rgb(255, 0, 0)) + .add_modifier(Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED); + + for (level, expected) in [ + (TerminalColorLevel::TrueColor, Color::Rgb(255, 0, 0)), + (TerminalColorLevel::Ansi256, Color::Indexed(196)), + (TerminalColorLevel::Ansi16, Color::LightRed), + (TerminalColorLevel::Monochrome, Color::Reset), + ] { + let adapted = level.adapt_style(style); + assert_eq!(adapted.underline_color, Some(expected), "{level:?}"); + assert_eq!(adapted.add_modifier, style.add_modifier, "{level:?}"); + } + } +} diff --git a/crates/orca-tui/src/terminal_presentation.rs b/crates/orca-tui/src/terminal_presentation.rs new file mode 100644 index 00000000..1b349a86 --- /dev/null +++ b/crates/orca-tui/src/terminal_presentation.rs @@ -0,0 +1,521 @@ +#![cfg_attr(not(test), allow(dead_code))] + +use std::collections::VecDeque; +use std::io::{self, Write}; + +use qwertty::{Multiplexer, TerminalIdentity, TerminalProgram}; + +use crate::selection::tmux_passthrough; +use crate::types::AppStatus; + +const MAX_PENDING_NOTIFICATIONS: usize = 32; +const MAX_NOTIFICATIONS_PER_WRITE: usize = 8; +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct TerminalPresentationProfile { + pub(crate) osc9_supported: bool, + pub(crate) tmux_passthrough: bool, +} + +impl TerminalPresentationProfile { + pub(crate) fn from_identity(identity: &TerminalIdentity) -> Self { + let osc9_supported = matches!( + identity.program, + Some( + TerminalProgram::Ghostty + | TerminalProgram::Iterm2 + | TerminalProgram::Kitty + | TerminalProgram::WezTerm + ) + ); + let tmux_passthrough = identity + .mux_stack + .iter() + .any(|multiplexer| matches!(multiplexer, Multiplexer::Tmux)); + Self { + osc9_supported, + tmux_passthrough, + } + } + + pub(crate) const fn osc9_supported(self) -> bool { + self.osc9_supported + } + + pub(crate) const fn tmux_passthrough(self) -> bool { + self.tmux_passthrough + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TerminalNotification { + message: String, +} + +impl TerminalNotification { + pub(crate) fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub(crate) fn message(&self) -> &str { + &self.message + } +} + +pub(crate) struct TerminalPresentation { + focused: bool, + notifications_enabled: bool, + output_failed: bool, + profile: TerminalPresentationProfile, + animation_tick: u64, + last_title: Option, + pending_notifications: VecDeque, +} + +impl TerminalPresentation { + pub(crate) fn new(notifications_enabled: bool, profile: TerminalPresentationProfile) -> Self { + Self { + focused: true, + notifications_enabled, + output_failed: false, + profile, + animation_tick: 0, + last_title: None, + pending_notifications: VecDeque::new(), + } + } + + pub(crate) fn set_focused(&mut self, focused: bool) { + self.focused = focused; + } + + pub(crate) const fn is_focused(&self) -> bool { + self.focused + } + + pub(crate) fn enqueue(&mut self, notification: TerminalNotification) { + if !self.notifications_enabled || self.focused { + return; + } + if self.pending_notifications.back() == Some(¬ification) { + return; + } + if self.pending_notifications.len() == MAX_PENDING_NOTIFICATIONS { + self.pending_notifications.pop_front(); + } + self.pending_notifications.push_back(notification); + } + + pub(crate) fn advance_tick(&mut self) { + self.animation_tick = self.animation_tick.wrapping_add(1); + } + + pub(crate) fn animation_active(&self, status: AppStatus) -> bool { + matches!( + status, + AppStatus::Running | AppStatus::Compacting | AppStatus::WaitingApproval + ) + } + + pub(crate) fn title(&self, status: AppStatus) -> String { + let spinner = SPINNER_FRAMES[self.animation_tick as usize % SPINNER_FRAMES.len()]; + match status { + AppStatus::Running => format!("{spinner} Orca"), + AppStatus::Compacting => format!("{spinner} Orca · compacting"), + AppStatus::WaitingApproval if (self.animation_tick / 6).is_multiple_of(2) => { + "[!] Orca".to_string() + } + AppStatus::WaitingUserInput => "[?] Orca".to_string(), + AppStatus::Setup + | AppStatus::SessionPicker + | AppStatus::Idle + | AppStatus::WaitingApproval => "Orca".to_string(), + } + } + + pub(crate) fn invalidate_title(&mut self) { + self.last_title = None; + } + + pub(crate) fn write_pending( + &mut self, + writer: &mut W, + status: AppStatus, + ) -> io::Result { + if self.output_failed { + return Ok(0); + } + + let result = (|| { + let title = self.title(status); + let mut writes = 0usize; + if self.last_title.as_deref() != Some(title.as_str()) { + writer.write_all(&encode_title(&title, self.profile))?; + self.last_title = Some(title); + writes += 1; + } + + for _ in 0..MAX_NOTIFICATIONS_PER_WRITE { + let Some(notification) = self.pending_notifications.pop_front() else { + break; + }; + writer.write_all(&encode_notification(¬ification.message, self.profile))?; + writes += 1; + } + if writes > 0 { + writer.flush()?; + } + Ok(writes) + })(); + if result.is_err() { + self.output_failed = true; + self.pending_notifications.clear(); + } + result + } + + pub(crate) fn write_reset_title(&mut self, writer: &mut W) -> io::Result { + if self.output_failed { + return Ok(0); + } + let result = (|| { + writer.write_all(&encode_title("Orca", self.profile))?; + writer.flush()?; + self.last_title = Some("Orca".to_string()); + Ok(1) + })(); + if result.is_err() { + self.output_failed = true; + self.pending_notifications.clear(); + } + result + } + + #[cfg(test)] + fn pending_len(&self) -> usize { + self.pending_notifications.len() + } + + #[cfg(test)] + pub(crate) fn pending_len_for_test(&self) -> usize { + self.pending_notifications.len() + } +} + +pub(crate) fn encode_notification(message: &str, profile: TerminalPresentationProfile) -> Vec { + if !profile.osc9_supported && !profile.tmux_passthrough { + return vec![0x07]; + } + let message = qwertty::commands::osc::sanitize_title(message); + let sequence = format!("\x1b]9;{message}\x1b\\"); + if profile.tmux_passthrough { + tmux_passthrough(&sequence).into_bytes() + } else { + sequence.into_bytes() + } +} + +pub(crate) fn encode_title(title: &str, profile: TerminalPresentationProfile) -> Vec { + let mut buffer = qwertty::CommandBuffer::new(); + buffer.command(qwertty::commands::osc::set_icon_and_title(title)); + let sequence = String::from_utf8(buffer.into_bytes()) + .expect("qwertty OSC title commands contain valid UTF-8"); + if profile.tmux_passthrough { + tmux_passthrough(&sequence).into_bytes() + } else { + sequence.into_bytes() + } +} + +#[cfg(test)] +mod tests { + use std::io::{self, Write}; + + use qwertty::caps::identity_from_env; + + use super::{ + TerminalNotification, TerminalPresentation, TerminalPresentationProfile, + encode_notification, encode_title, + }; + use crate::types::AppStatus; + + fn identity(values: &[(&str, &str)]) -> qwertty::TerminalIdentity { + identity_from_env(None, |key| { + values + .iter() + .find_map(|(candidate, value)| (*candidate == key).then(|| (*value).to_string())) + }) + } + + fn direct_profile() -> TerminalPresentationProfile { + TerminalPresentationProfile { + osc9_supported: true, + tmux_passthrough: false, + } + } + + fn unknown_profile() -> TerminalPresentationProfile { + TerminalPresentationProfile { + osc9_supported: false, + tmux_passthrough: false, + } + } + + fn presentation(profile: TerminalPresentationProfile) -> TerminalPresentation { + TerminalPresentation::new(true, profile) + } + + #[test] + fn terminal_presentation_profile_classifies_known_programs_and_tmux() { + for environment in [ + [("TERM_PROGRAM", "ghostty")], + [("TERM_PROGRAM", "iTerm.app")], + [("TERM", "xterm-kitty")], + [("TERM_PROGRAM", "WezTerm")], + ] { + assert_eq!( + TerminalPresentationProfile::from_identity(&identity(&environment)), + direct_profile() + ); + } + + for environment in [ + [("TERM", "alacritty")], + [("TERM_PROGRAM", "Apple_Terminal")], + [("TERM", "mystery")], + ] { + assert_eq!( + TerminalPresentationProfile::from_identity(&identity(&environment)), + unknown_profile() + ); + } + + assert_eq!( + TerminalPresentationProfile::from_identity(&identity(&[("TMUX", "x")])), + TerminalPresentationProfile { + osc9_supported: false, + tmux_passthrough: true, + } + ); + } + + #[test] + fn terminal_presentation_encodes_osc9_bel_osc0_and_tmux() { + assert_eq!( + encode_notification("done", direct_profile()), + b"\x1b]9;done\x1b\\".to_vec() + ); + assert_eq!( + encode_notification("done", unknown_profile()), + b"\x07".to_vec() + ); + assert_eq!( + encode_title("Orca", direct_profile()), + b"\x1b]0;Orca\x1b\\".to_vec() + ); + + let tmux = TerminalPresentationProfile { + osc9_supported: false, + tmux_passthrough: true, + }; + assert_eq!( + encode_notification("done", tmux), + b"\x1bPtmux;\x1b\x1b]9;done\x1b\x1b\\\x1b\\".to_vec() + ); + assert_eq!( + encode_title("Orca", tmux), + b"\x1bPtmux;\x1b\x1b]0;Orca\x1b\x1b\\\x1b\\".to_vec() + ); + } + + #[test] + fn terminal_presentation_sanitizes_and_bounds_notification_text() { + let malicious = format!("safe\x1b]0;spoof\x07\u{202e}{}", "x".repeat(400)); + let encoded = String::from_utf8(encode_notification(&malicious, direct_profile())).unwrap(); + + assert!(encoded.starts_with("\x1b]9;safe]0;spoof")); + assert!(encoded.ends_with("\x1b\\")); + assert!(!encoded.contains('\u{202e}')); + assert!(!encoded[4..encoded.len() - 2].contains('\x1b')); + assert!(encoded.chars().count() <= 4 + 240 + 2); + } + + #[test] + fn terminal_presentation_title_matrix_and_animation_are_stable() { + let mut presentation = presentation(direct_profile()); + + for status in [AppStatus::Setup, AppStatus::SessionPicker, AppStatus::Idle] { + assert_eq!(presentation.title(status), "Orca"); + } + assert_eq!(presentation.title(AppStatus::Running), "⠋ Orca"); + assert_eq!( + presentation.title(AppStatus::Compacting), + "⠋ Orca · compacting" + ); + assert_eq!(presentation.title(AppStatus::WaitingApproval), "[!] Orca"); + assert_eq!(presentation.title(AppStatus::WaitingUserInput), "[?] Orca"); + + for _ in 0..6 { + presentation.advance_tick(); + } + assert_eq!(presentation.title(AppStatus::WaitingApproval), "Orca"); + assert_eq!(presentation.title(AppStatus::Running), "⠦ Orca"); + assert!(presentation.animation_active(AppStatus::Running)); + assert!(presentation.animation_active(AppStatus::Compacting)); + assert!(presentation.animation_active(AppStatus::WaitingApproval)); + assert!(!presentation.animation_active(AppStatus::Idle)); + assert!(!presentation.animation_active(AppStatus::WaitingUserInput)); + } + + #[test] + fn terminal_presentation_queue_is_bounded_deduplicated_and_drained_eight_at_a_time() { + let mut presentation = presentation(direct_profile()); + presentation.set_focused(false); + + presentation.enqueue(TerminalNotification::new("same")); + presentation.enqueue(TerminalNotification::new("same")); + assert_eq!(presentation.pending_len(), 1); + + for index in 0..40 { + presentation.enqueue(TerminalNotification::new(format!("item-{index}"))); + } + assert_eq!(presentation.pending_len(), 32); + + let mut output = Vec::new(); + let writes = presentation + .write_pending(&mut output, AppStatus::Idle) + .unwrap(); + assert_eq!(writes, 9, "one title plus eight notifications"); + assert_eq!(presentation.pending_len(), 24); + assert_eq!( + output + .windows(4) + .filter(|bytes| *bytes == b"\x1b]9;") + .count(), + 8 + ); + } + + #[test] + fn terminal_presentation_suppresses_focused_and_disabled_notifications() { + let mut presentation = presentation(direct_profile()); + presentation.enqueue(TerminalNotification::new("focused")); + assert_eq!(presentation.pending_len(), 0); + + presentation.set_focused(false); + presentation.enqueue(TerminalNotification::new("unfocused")); + assert_eq!(presentation.pending_len(), 1); + + let mut disabled = TerminalPresentation::new(false, direct_profile()); + disabled.set_focused(false); + disabled.enqueue(TerminalNotification::new("disabled")); + assert_eq!(disabled.pending_len(), 0); + } + + #[test] + fn terminal_presentation_deduplicates_title_and_invalidation_reemits() { + let mut presentation = presentation(direct_profile()); + let mut output = Vec::new(); + + assert_eq!( + presentation + .write_pending(&mut output, AppStatus::Idle) + .unwrap(), + 1 + ); + assert_eq!( + presentation + .write_pending(&mut output, AppStatus::Idle) + .unwrap(), + 0 + ); + presentation.invalidate_title(); + assert_eq!( + presentation + .write_pending(&mut output, AppStatus::Idle) + .unwrap(), + 1 + ); + assert_eq!( + output + .windows(4) + .filter(|bytes| *bytes == b"\x1b]0;") + .count(), + 2 + ); + } + + struct FailingWriter { + writes: usize, + } + + impl Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + self.writes += 1; + Err(io::Error::new(io::ErrorKind::BrokenPipe, "writer failed")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn terminal_presentation_writer_returns_first_error_without_retrying() { + let mut presentation = presentation(direct_profile()); + let mut writer = FailingWriter { writes: 0 }; + let error = presentation + .write_pending(&mut writer, AppStatus::Running) + .expect_err("writer should fail"); + + assert_eq!(error.kind(), io::ErrorKind::BrokenPipe); + assert_eq!(writer.writes, 1); + } + + #[test] + fn terminal_presentation_disables_all_output_after_first_write_error() { + let mut presentation = presentation(direct_profile()); + presentation.set_focused(false); + presentation.enqueue(TerminalNotification::new("Approval required")); + let mut writer = FailingWriter { writes: 0 }; + + assert!( + presentation + .write_pending(&mut writer, AppStatus::Running) + .is_err() + ); + assert_eq!( + presentation + .write_pending(&mut writer, AppStatus::Running) + .unwrap(), + 0 + ); + assert_eq!(presentation.write_reset_title(&mut writer).unwrap(), 0); + assert_eq!(writer.writes, 1, "failed output must never be retried"); + assert_eq!( + presentation.pending_len(), + 0, + "undeliverable notifications are discarded" + ); + } + + #[test] + fn terminal_presentation_reset_title_writes_orca_even_after_orca_was_cached() { + let mut presentation = presentation(direct_profile()); + let mut output = Vec::new(); + presentation + .write_pending(&mut output, AppStatus::Idle) + .unwrap(); + let before_reset = output.len(); + + assert_eq!(presentation.write_reset_title(&mut output).unwrap(), 1); + assert_eq!( + &output[before_reset..], + b"\x1b]0;Orca\x1b\\", + "orderly exit always reasserts the safe title" + ); + } +} diff --git a/crates/orca-tui/src/theme.rs b/crates/orca-tui/src/theme.rs index ee9ca762..c5c7157b 100644 --- a/crates/orca-tui/src/theme.rs +++ b/crates/orca-tui/src/theme.rs @@ -1,7 +1,13 @@ -use ratatui::style::Color; +use ratatui::style::{Color, Modifier, Style}; use orca_core::config::ThemeName; +use crate::syntax_highlight::SyntaxTheme; +use crate::terminal_capabilities::{ + TerminalBackground, TerminalColorLevel, TerminalProfile, resolve_base_theme, + syntax_style_revision, +}; + #[derive(Clone, Copy, Debug)] pub struct Theme { pub border: Color, @@ -13,14 +19,39 @@ pub struct Theme { pub error: Color, pub approval: Color, pub plan_mode: Color, + pub markdown_h1: Color, + pub markdown_h2: Color, + pub markdown_h3: Color, + pub markdown_inline_code: Color, pub diff_add: Color, pub diff_remove: Color, + pub diff_add_bg: Color, + pub diff_remove_bg: Color, + pub diff_add_emphasis_bg: Color, + pub diff_remove_emphasis_bg: Color, /// Background for the mouse text selection in the transcript. pub selection_bg: Color, + pub search_match_bg: Color, + pub search_match_active_bg: Color, + pub(crate) syntax_theme: SyntaxTheme, + pub(crate) color_level: TerminalColorLevel, + pub(crate) syntax_theme_revision: u64, } impl Theme { - pub fn named(name: ThemeName) -> Self { + fn base(name: ThemeName) -> Self { + if name == ThemeName::Auto { + return Self::base(ThemeName::Dark); + } + + let syntax_theme = match name { + ThemeName::Dark => SyntaxTheme::OneHalfDark, + ThemeName::Light => SyntaxTheme::OneHalfLight, + ThemeName::Solarized => SyntaxTheme::SolarizedDark, + ThemeName::Catppuccin => SyntaxTheme::CatppuccinMocha, + ThemeName::Auto => unreachable!(), + }; + match name { // DeepSeek-blue truecolor palette. Brand accent #4D6BFE drives // borders, selection, and the user prompt. @@ -34,10 +65,23 @@ impl Theme { error: Color::Rgb(214, 81, 81), approval: Color::Rgb(169, 139, 245), plan_mode: Color::Rgb(64, 170, 170), + markdown_h1: Color::Rgb(77, 107, 254), + markdown_h2: Color::Rgb(169, 139, 245), + markdown_h3: Color::Rgb(217, 164, 65), + markdown_inline_code: Color::Rgb(64, 170, 170), diff_add: Color::Rgb(47, 177, 112), diff_remove: Color::Rgb(214, 81, 81), + diff_add_bg: Color::Rgb(0x21, 0x3a, 0x2b), + diff_remove_bg: Color::Rgb(0x4a, 0x22, 0x1d), + diff_add_emphasis_bg: Color::Rgb(0x31, 0x5c, 0x40), + diff_remove_emphasis_bg: Color::Rgb(0x71, 0x35, 0x2a), // Muted brand blue: keeps every foreground legible. selection_bg: Color::Rgb(46, 62, 132), + search_match_bg: Color::Rgb(78, 67, 31), + search_match_active_bg: Color::Rgb(77, 107, 254), + syntax_theme, + color_level: TerminalColorLevel::TrueColor, + syntax_theme_revision: syntax_theme.revision(), }, ThemeName::Light => Self { border: Color::Rgb(58, 86, 230), @@ -49,9 +93,22 @@ impl Theme { error: Color::Rgb(196, 52, 52), approval: Color::Rgb(138, 92, 230), plan_mode: Color::Rgb(0, 102, 102), + markdown_h1: Color::Rgb(58, 86, 230), + markdown_h2: Color::Rgb(138, 92, 230), + markdown_h3: Color::Rgb(176, 122, 20), + markdown_inline_code: Color::Rgb(0, 102, 102), diff_add: Color::Rgb(31, 142, 86), diff_remove: Color::Rgb(196, 52, 52), + diff_add_bg: Color::Rgb(0xdc, 0xfc, 0xe7), + diff_remove_bg: Color::Rgb(0xfe, 0xe2, 0xe2), + diff_add_emphasis_bg: Color::Rgb(0x86, 0xef, 0xac), + diff_remove_emphasis_bg: Color::Rgb(0xfc, 0xa5, 0xa5), selection_bg: Color::Rgb(198, 210, 250), + search_match_bg: Color::Rgb(255, 235, 153), + search_match_active_bg: Color::Rgb(166, 188, 255), + syntax_theme, + color_level: TerminalColorLevel::TrueColor, + syntax_theme_revision: syntax_theme.revision(), }, ThemeName::Solarized => Self { border: Color::Rgb(38, 139, 210), @@ -63,10 +120,23 @@ impl Theme { error: Color::Rgb(220, 50, 47), approval: Color::Rgb(108, 113, 196), plan_mode: Color::Rgb(42, 161, 152), + markdown_h1: Color::Rgb(38, 139, 210), + markdown_h2: Color::Rgb(42, 161, 152), + markdown_h3: Color::Rgb(181, 137, 0), + markdown_inline_code: Color::Rgb(211, 54, 130), diff_add: Color::Rgb(133, 153, 0), diff_remove: Color::Rgb(220, 50, 47), + diff_add_bg: Color::Rgb(0x16, 0x3c, 0x3a), + diff_remove_bg: Color::Rgb(0x4c, 0x2a, 0x2a), + diff_add_emphasis_bg: Color::Rgb(0x24, 0x5b, 0x52), + diff_remove_emphasis_bg: Color::Rgb(0x71, 0x3c, 0x35), // base02, Solarized's canonical selection background. selection_bg: Color::Rgb(7, 54, 66), + search_match_bg: Color::Rgb(88, 73, 0), + search_match_active_bg: Color::Rgb(38, 139, 210), + syntax_theme, + color_level: TerminalColorLevel::TrueColor, + syntax_theme_revision: syntax_theme.revision(), }, ThemeName::Catppuccin => Self { border: Color::Rgb(203, 166, 247), @@ -78,11 +148,530 @@ impl Theme { error: Color::Rgb(243, 139, 168), approval: Color::Rgb(203, 166, 247), plan_mode: Color::Rgb(148, 226, 213), + markdown_h1: Color::Rgb(203, 166, 247), + markdown_h2: Color::Rgb(116, 199, 236), + markdown_h3: Color::Rgb(249, 226, 175), + markdown_inline_code: Color::Rgb(245, 194, 231), diff_add: Color::Rgb(166, 227, 161), diff_remove: Color::Rgb(243, 139, 168), + diff_add_bg: Color::Rgb(0x29, 0x44, 0x36), + diff_remove_bg: Color::Rgb(0x4a, 0x30, 0x3a), + diff_add_emphasis_bg: Color::Rgb(0x3d, 0x65, 0x4d), + diff_remove_emphasis_bg: Color::Rgb(0x70, 0x45, 0x55), // surface2 from the Mocha palette. selection_bg: Color::Rgb(88, 91, 112), + search_match_bg: Color::Rgb(88, 91, 112), + search_match_active_bg: Color::Rgb(137, 180, 250), + syntax_theme, + color_level: TerminalColorLevel::TrueColor, + syntax_theme_revision: syntax_theme.revision(), + }, + ThemeName::Auto => unreachable!(), + } + } + + pub fn named(name: ThemeName) -> Self { + Self::resolve( + name, + TerminalProfile { + background: TerminalBackground::Unknown, + color_level: TerminalColorLevel::TrueColor, + }, + ) + } + + pub(crate) fn resolve(name: ThemeName, profile: TerminalProfile) -> Self { + let mut theme = Self::base(resolve_base_theme(name, profile.background)); + let adapt = |color| profile.color_level.adapt_color(color); + theme.border = adapt(theme.border); + theme.text = adapt(theme.text); + theme.muted = adapt(theme.muted); + theme.user = adapt(theme.user); + theme.success = adapt(theme.success); + theme.warning = adapt(theme.warning); + theme.error = adapt(theme.error); + theme.approval = adapt(theme.approval); + theme.plan_mode = adapt(theme.plan_mode); + theme.markdown_h1 = adapt(theme.markdown_h1); + theme.markdown_h2 = adapt(theme.markdown_h2); + theme.markdown_h3 = adapt(theme.markdown_h3); + theme.markdown_inline_code = adapt(theme.markdown_inline_code); + theme.diff_add = adapt(theme.diff_add); + theme.diff_remove = adapt(theme.diff_remove); + theme.diff_add_bg = adapt(theme.diff_add_bg); + theme.diff_remove_bg = adapt(theme.diff_remove_bg); + theme.diff_add_emphasis_bg = adapt(theme.diff_add_emphasis_bg); + theme.diff_remove_emphasis_bg = adapt(theme.diff_remove_emphasis_bg); + theme.selection_bg = adapt(theme.selection_bg); + theme.search_match_bg = adapt(theme.search_match_bg); + theme.search_match_active_bg = adapt(theme.search_match_active_bg); + theme.color_level = profile.color_level; + theme.syntax_theme_revision = + syntax_style_revision(theme.syntax_theme, profile.color_level); + theme + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn selection_style(self) -> Style { + match self.color_level { + TerminalColorLevel::Monochrome => Style::default().add_modifier(Modifier::REVERSED), + _ => Style::default().bg(self.selection_bg), + } + } + + pub(crate) fn search_match_style(self) -> Style { + match self.color_level { + TerminalColorLevel::Monochrome => Style::default().add_modifier(Modifier::UNDERLINED), + _ => Style::default().bg(self.search_match_bg), + } + } + + pub(crate) fn search_match_active_style(self) -> Style { + match self.color_level { + TerminalColorLevel::Monochrome => { + Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD) + } + _ => Style::default() + .bg(self.search_match_active_bg) + .add_modifier(Modifier::BOLD), + } + } +} + +#[cfg(test)] +mod tests { + use orca_core::config::ThemeName; + use ratatui::style::{Color, Modifier, Style}; + + use super::Theme; + use crate::syntax_highlight::SyntaxTheme; + use crate::terminal_capabilities::{TerminalBackground, TerminalColorLevel, TerminalProfile}; + + fn theme_colors(theme: Theme) -> [Color; 22] { + [ + theme.border, + theme.text, + theme.muted, + theme.user, + theme.success, + theme.warning, + theme.error, + theme.approval, + theme.plan_mode, + theme.markdown_h1, + theme.markdown_h2, + theme.markdown_h3, + theme.markdown_inline_code, + theme.diff_add, + theme.diff_remove, + theme.diff_add_bg, + theme.diff_remove_bg, + theme.diff_add_emphasis_bg, + theme.diff_remove_emphasis_bg, + theme.selection_bg, + theme.search_match_bg, + theme.search_match_active_bg, + ] + } + + fn color_fits_level(level: TerminalColorLevel, color: Color) -> bool { + match level { + TerminalColorLevel::TrueColor => true, + TerminalColorLevel::Ansi256 => !matches!(color, Color::Rgb(..)), + TerminalColorLevel::Ansi16 => !matches!(color, Color::Rgb(..) | Color::Indexed(_)), + TerminalColorLevel::Monochrome => color == Color::Reset, + } + } + + fn assert_theme_colors_fit_level(theme: Theme) { + assert!( + theme_colors(theme) + .into_iter() + .all(|color| color_fits_level(theme.color_level, color)), + "{theme:?}" + ); + } + + #[test] + fn named_auto_uses_the_existing_dark_palette_without_terminal_context() { + let auto = Theme::named(ThemeName::Auto); + let dark = Theme::named(ThemeName::Dark); + assert_eq!(theme_colors(auto), theme_colors(dark)); + assert_eq!(auto.syntax_theme, dark.syntax_theme); + assert_eq!(auto.color_level, TerminalColorLevel::TrueColor); + } + + #[test] + fn named_themes_preserve_exact_truecolor_palettes() { + let cases = [ + ( + ThemeName::Dark, + [ + Color::Rgb(77, 107, 254), + Color::Rgb(232, 236, 246), + Color::Rgb(139, 147, 167), + Color::Rgb(77, 107, 254), + Color::Rgb(47, 177, 112), + Color::Rgb(217, 164, 65), + Color::Rgb(214, 81, 81), + Color::Rgb(169, 139, 245), + Color::Rgb(64, 170, 170), + Color::Rgb(77, 107, 254), + Color::Rgb(169, 139, 245), + Color::Rgb(217, 164, 65), + Color::Rgb(64, 170, 170), + Color::Rgb(47, 177, 112), + Color::Rgb(214, 81, 81), + Color::Rgb(0x21, 0x3a, 0x2b), + Color::Rgb(0x4a, 0x22, 0x1d), + Color::Rgb(0x31, 0x5c, 0x40), + Color::Rgb(0x71, 0x35, 0x2a), + Color::Rgb(46, 62, 132), + Color::Rgb(78, 67, 31), + Color::Rgb(77, 107, 254), + ], + ), + ( + ThemeName::Light, + [ + Color::Rgb(58, 86, 230), + Color::Rgb(28, 32, 44), + Color::Rgb(110, 118, 138), + Color::Rgb(58, 86, 230), + Color::Rgb(31, 142, 86), + Color::Rgb(176, 122, 20), + Color::Rgb(196, 52, 52), + Color::Rgb(138, 92, 230), + Color::Rgb(0, 102, 102), + Color::Rgb(58, 86, 230), + Color::Rgb(138, 92, 230), + Color::Rgb(176, 122, 20), + Color::Rgb(0, 102, 102), + Color::Rgb(31, 142, 86), + Color::Rgb(196, 52, 52), + Color::Rgb(0xdc, 0xfc, 0xe7), + Color::Rgb(0xfe, 0xe2, 0xe2), + Color::Rgb(0x86, 0xef, 0xac), + Color::Rgb(0xfc, 0xa5, 0xa5), + Color::Rgb(198, 210, 250), + Color::Rgb(255, 235, 153), + Color::Rgb(166, 188, 255), + ], + ), + ( + ThemeName::Solarized, + [ + Color::Rgb(38, 139, 210), + Color::Rgb(147, 161, 161), + Color::Rgb(88, 110, 117), + Color::Rgb(38, 139, 210), + Color::Rgb(133, 153, 0), + Color::Rgb(181, 137, 0), + Color::Rgb(220, 50, 47), + Color::Rgb(108, 113, 196), + Color::Rgb(42, 161, 152), + Color::Rgb(38, 139, 210), + Color::Rgb(42, 161, 152), + Color::Rgb(181, 137, 0), + Color::Rgb(211, 54, 130), + Color::Rgb(133, 153, 0), + Color::Rgb(220, 50, 47), + Color::Rgb(0x16, 0x3c, 0x3a), + Color::Rgb(0x4c, 0x2a, 0x2a), + Color::Rgb(0x24, 0x5b, 0x52), + Color::Rgb(0x71, 0x3c, 0x35), + Color::Rgb(7, 54, 66), + Color::Rgb(88, 73, 0), + Color::Rgb(38, 139, 210), + ], + ), + ( + ThemeName::Catppuccin, + [ + Color::Rgb(203, 166, 247), + Color::Rgb(205, 214, 244), + Color::Rgb(147, 153, 178), + Color::Rgb(137, 220, 235), + Color::Rgb(166, 227, 161), + Color::Rgb(249, 226, 175), + Color::Rgb(243, 139, 168), + Color::Rgb(203, 166, 247), + Color::Rgb(148, 226, 213), + Color::Rgb(203, 166, 247), + Color::Rgb(116, 199, 236), + Color::Rgb(249, 226, 175), + Color::Rgb(245, 194, 231), + Color::Rgb(166, 227, 161), + Color::Rgb(243, 139, 168), + Color::Rgb(0x29, 0x44, 0x36), + Color::Rgb(0x4a, 0x30, 0x3a), + Color::Rgb(0x3d, 0x65, 0x4d), + Color::Rgb(0x70, 0x45, 0x55), + Color::Rgb(88, 91, 112), + Color::Rgb(88, 91, 112), + Color::Rgb(137, 180, 250), + ], + ), + ]; + + for (name, expected) in cases { + assert_eq!(theme_colors(Theme::named(name)), expected, "{name:?}"); + } + } + + #[test] + fn dark_diff_backgrounds_match_the_review_palette() { + let theme = Theme::named(ThemeName::Dark); + assert_eq!(theme.diff_add_bg, Color::Rgb(0x21, 0x3a, 0x2b)); + assert_eq!(theme.diff_remove_bg, Color::Rgb(0x4a, 0x22, 0x1d)); + assert_eq!(theme.diff_add_emphasis_bg, Color::Rgb(0x31, 0x5c, 0x40)); + assert_eq!(theme.diff_remove_emphasis_bg, Color::Rgb(0x71, 0x35, 0x2a)); + } + + #[test] + fn resolved_themes_choose_base_palette_and_obey_color_level() { + for (requested, background, expected) in [ + ( + ThemeName::Auto, + TerminalBackground::Light, + Theme::named(ThemeName::Light), + ), + ( + ThemeName::Auto, + TerminalBackground::Dark, + Theme::named(ThemeName::Dark), + ), + ( + ThemeName::Auto, + TerminalBackground::Unknown, + Theme::named(ThemeName::Dark), + ), + ( + ThemeName::Solarized, + TerminalBackground::Light, + Theme::named(ThemeName::Solarized), + ), + ] { + let resolved = Theme::resolve( + requested, + TerminalProfile { + background, + color_level: TerminalColorLevel::TrueColor, + }, + ); + assert_eq!(theme_colors(resolved), theme_colors(expected)); + assert_eq!(resolved.syntax_theme, expected.syntax_theme); + } + + for level in [ + TerminalColorLevel::Ansi256, + TerminalColorLevel::Ansi16, + TerminalColorLevel::Monochrome, + ] { + for name in [ + ThemeName::Dark, + ThemeName::Light, + ThemeName::Solarized, + ThemeName::Catppuccin, + ] { + assert_theme_colors_fit_level(Theme::resolve( + name, + TerminalProfile { + background: TerminalBackground::Unknown, + color_level: level, + }, + )); + } + } + } + + #[test] + fn resolved_theme_revisions_preserve_truecolor_and_encode_color_level() { + for (name, expected_revision) in [ + (ThemeName::Dark, 1), + (ThemeName::Light, 2), + (ThemeName::Solarized, 3), + (ThemeName::Catppuccin, 4), + ] { + for (level, offset) in [ + (TerminalColorLevel::TrueColor, 0), + (TerminalColorLevel::Ansi256, 0x100), + (TerminalColorLevel::Ansi16, 0x200), + (TerminalColorLevel::Monochrome, 0x300), + ] { + let theme = Theme::resolve( + name, + TerminalProfile { + background: TerminalBackground::Unknown, + color_level: level, + }, + ); + assert_eq!(theme.syntax_theme_revision, expected_revision + offset); + } + } + } + + #[test] + fn selection_style_uses_adapted_background_or_monochrome_reversal() { + let color_theme = Theme::resolve( + ThemeName::Dark, + TerminalProfile { + background: TerminalBackground::Unknown, + color_level: TerminalColorLevel::Ansi16, }, + ); + assert_eq!( + color_theme.selection_style(), + Style::default().bg(color_theme.selection_bg) + ); + + let monochrome = Theme::resolve( + ThemeName::Dark, + TerminalProfile { + background: TerminalBackground::Unknown, + color_level: TerminalColorLevel::Monochrome, + }, + ); + assert_eq!( + monochrome.selection_style(), + Style::default().add_modifier(Modifier::REVERSED) + ); + } + + #[test] + fn search_styles_are_distinct_and_capability_safe() { + for name in [ + ThemeName::Dark, + ThemeName::Light, + ThemeName::Solarized, + ThemeName::Catppuccin, + ] { + for level in [ + TerminalColorLevel::TrueColor, + TerminalColorLevel::Ansi256, + TerminalColorLevel::Ansi16, + TerminalColorLevel::Monochrome, + ] { + let theme = Theme::resolve( + name, + TerminalProfile { + background: TerminalBackground::Unknown, + color_level: level, + }, + ); + assert_ne!( + theme.search_match_style(), + theme.search_match_active_style() + ); + if level == TerminalColorLevel::Monochrome { + assert!( + theme + .search_match_style() + .add_modifier + .contains(Modifier::UNDERLINED) + ); + assert!( + theme + .search_match_active_style() + .add_modifier + .contains(Modifier::REVERSED) + ); + } + } + } + } + + #[test] + fn named_themes_map_to_matching_syntax_themes_and_revisions() { + let cases = [ + (ThemeName::Dark, SyntaxTheme::OneHalfDark), + (ThemeName::Light, SyntaxTheme::OneHalfLight), + (ThemeName::Solarized, SyntaxTheme::SolarizedDark), + (ThemeName::Catppuccin, SyntaxTheme::CatppuccinMocha), + ]; + + for (name, syntax_theme) in cases { + let theme = Theme::named(name); + assert_eq!(theme.syntax_theme, syntax_theme); + assert_eq!(theme.syntax_theme_revision, syntax_theme.revision()); + } + } + + #[test] + fn named_themes_define_markdown_semantic_colors() { + let cases = [ + ( + ThemeName::Dark, + [ + Color::Rgb(77, 107, 254), + Color::Rgb(169, 139, 245), + Color::Rgb(217, 164, 65), + Color::Rgb(64, 170, 170), + ], + ), + ( + ThemeName::Light, + [ + Color::Rgb(58, 86, 230), + Color::Rgb(138, 92, 230), + Color::Rgb(176, 122, 20), + Color::Rgb(0, 102, 102), + ], + ), + ( + ThemeName::Solarized, + [ + Color::Rgb(38, 139, 210), + Color::Rgb(42, 161, 152), + Color::Rgb(181, 137, 0), + Color::Rgb(211, 54, 130), + ], + ), + ( + ThemeName::Catppuccin, + [ + Color::Rgb(203, 166, 247), + Color::Rgb(116, 199, 236), + Color::Rgb(249, 226, 175), + Color::Rgb(245, 194, 231), + ], + ), + ]; + + for (name, expected) in cases { + let theme = Theme::named(name); + assert_eq!( + [ + theme.markdown_h1, + theme.markdown_h2, + theme.markdown_h3, + theme.markdown_inline_code, + ], + expected, + "{name:?}" + ); + } + } + + #[test] + fn markdown_semantic_colors_do_not_use_fixed_ansi_accents() { + let forbidden = [Color::Cyan, Color::Green, Color::Yellow, Color::Magenta]; + + for name in [ + ThemeName::Dark, + ThemeName::Light, + ThemeName::Solarized, + ThemeName::Catppuccin, + ] { + let theme = Theme::named(name); + for color in [ + theme.markdown_h1, + theme.markdown_h2, + theme.markdown_h3, + theme.markdown_inline_code, + ] { + assert!(!forbidden.contains(&color), "{name:?}: {color:?}"); + } } } } diff --git a/crates/orca-tui/src/transcript_search.rs b/crates/orca-tui/src/transcript_search.rs new file mode 100644 index 00000000..3efa7762 --- /dev/null +++ b/crates/orca-tui/src/transcript_search.rs @@ -0,0 +1,469 @@ +use std::ops::Range; + +use crate::selection::SelectionPos; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SearchQuery { + original: String, + needle: String, + case_sensitive: bool, +} + +impl SearchQuery { + pub(crate) fn new(query: &str) -> Self { + let case_sensitive = query.chars().any(char::is_uppercase); + let needle = if case_sensitive { + query.to_string() + } else { + query.chars().flat_map(char::to_lowercase).collect() + }; + Self { + original: query.to_string(), + needle, + case_sensitive, + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.original.is_empty() + } + + pub(crate) fn find_ranges(&self, text: &str) -> Vec> { + if self.is_empty() { + return Vec::new(); + } + if self.case_sensitive { + return text + .match_indices(&self.needle) + .map(|(start, matched)| start..start + matched.len()) + .collect(); + } + + let mut folded = String::new(); + let mut boundaries = Vec::with_capacity(text.chars().count() + 1); + for (original_offset, character) in text.char_indices() { + boundaries.push((folded.len(), original_offset)); + folded.extend(character.to_lowercase()); + } + boundaries.push((folded.len(), text.len())); + + folded + .match_indices(&self.needle) + .filter_map(|(start, matched)| { + let end = start + matched.len(); + let original_start = boundaries + .binary_search_by_key(&start, |(folded, _)| *folded) + .ok() + .map(|index| boundaries[index].1)?; + let original_end = boundaries + .binary_search_by_key(&end, |(folded, _)| *folded) + .ok() + .map(|index| boundaries[index].1)?; + Some(original_start..original_end) + }) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(crate) struct TranscriptLineIdentity { + pub(crate) message_revision: u64, + pub(crate) line_index: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TranscriptSearchMatch { + pub(crate) start: SelectionPos, + pub(crate) end: SelectionPos, + pub(crate) line_identity: TranscriptLineIdentity, + pub(crate) byte_range: Range, +} + +impl TranscriptSearchMatch { + pub(crate) fn new( + start: SelectionPos, + end: SelectionPos, + line_identity: TranscriptLineIdentity, + byte_range: Range, + ) -> Self { + Self { + start, + end, + line_identity, + byte_range, + } + } + + pub(crate) fn last_covered_row(&self) -> usize { + if self.end.row > self.start.row && self.end.col == 0 { + self.end.row - 1 + } else { + self.end.row + } + } + + pub(crate) fn cols_on_row(&self, row: usize) -> Option<(usize, Option)> { + let last_row = self.last_covered_row(); + if row < self.start.row || row > last_row { + return None; + } + if self.start.row == self.end.row { + return (self.start.col < self.end.col).then_some((self.start.col, Some(self.end.col))); + } + if row == self.start.row { + Some((self.start.col, None)) + } else if row == last_row { + if self.end.col == 0 { + Some((0, None)) + } else { + Some((0, Some(self.end.col))) + } + } else { + Some((0, None)) + } + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct TranscriptSearchState { + pub(crate) open: bool, + query: String, + cursor: usize, + matches: Vec, + active: Option, + prepared_generation: Option, + prepared_query: String, + #[cfg(test)] + scan_count: usize, +} + +impl TranscriptSearchState { + pub(crate) fn open_new(&mut self) { + if self.open { + return; + } + self.open = true; + self.query.clear(); + self.cursor = 0; + self.matches.clear(); + self.active = None; + self.invalidate_prepared(); + } + + pub(crate) fn close(&mut self) { + self.open = false; + } + + pub(crate) fn reset(&mut self) { + *self = Self::default(); + } + + pub(crate) fn query(&self) -> &str { + &self.query + } + + pub(crate) fn cursor(&self) -> usize { + self.cursor + } + + pub(crate) fn has_query(&self) -> bool { + !self.query.is_empty() + } + + pub(crate) fn insert_char(&mut self, character: char) { + self.query.insert(self.cursor, character); + self.cursor += character.len_utf8(); + self.invalidate_prepared(); + } + + pub(crate) fn insert_paste(&mut self, pasted: &str) { + let normalized = pasted + .replace("\r\n", "\n") + .replace('\r', "\n") + .split('\n') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::>() + .join(" "); + self.query.insert_str(self.cursor, &normalized); + self.cursor += normalized.len(); + self.invalidate_prepared(); + } + + pub(crate) fn backspace(&mut self) -> bool { + let Some(previous) = self.query[..self.cursor] + .char_indices() + .next_back() + .map(|(index, _)| index) + else { + return false; + }; + self.query.drain(previous..self.cursor); + self.cursor = previous; + self.invalidate_prepared(); + true + } + + pub(crate) fn move_left(&mut self) { + self.cursor = self.query[..self.cursor] + .char_indices() + .next_back() + .map_or(0, |(index, _)| index); + } + + pub(crate) fn move_right(&mut self) { + self.cursor = self.query[self.cursor..] + .chars() + .next() + .map_or(self.query.len(), |character| { + self.cursor + character.len_utf8() + }); + } + + pub(crate) fn move_home(&mut self) { + self.cursor = 0; + } + + pub(crate) fn move_end(&mut self) { + self.cursor = self.query.len(); + } + + pub(crate) fn clear_query(&mut self) { + self.query.clear(); + self.cursor = 0; + self.matches.clear(); + self.active = None; + self.invalidate_prepared(); + } + + pub(crate) fn replace_query(&mut self, query: &str) { + self.query.clear(); + self.query.push_str(query); + self.cursor = self.query.len(); + self.invalidate_prepared(); + } + + pub(crate) fn refresh_with( + &mut self, + generation: u64, + viewport_base: usize, + search: impl FnOnce(&SearchQuery) -> Vec, + ) { + if self.prepared_generation == Some(generation) && self.prepared_query == self.query { + return; + } + + let same_query = self.prepared_query == self.query; + let previous = same_query.then(|| self.active_match().cloned()).flatten(); + let query = SearchQuery::new(&self.query); + let matches = if query.is_empty() { + Vec::new() + } else { + #[cfg(test)] + { + self.scan_count += 1; + } + search(&query) + }; + + let active = if matches.is_empty() { + None + } else if let Some(previous) = previous.as_ref() { + matches + .iter() + .position(|found| { + found.line_identity == previous.line_identity + && found.byte_range == previous.byte_range + }) + .or_else(|| { + let next = matches.partition_point(|found| found.start < previous.start); + Some(if next == matches.len() { 0 } else { next }) + }) + } else { + let next = matches.partition_point(|found| found.start.row < viewport_base); + Some(if next == matches.len() { 0 } else { next }) + }; + + self.matches = matches; + self.active = active; + self.prepared_generation = Some(generation); + self.prepared_query.clone_from(&self.query); + } + + pub(crate) fn next(&mut self) -> Option<&TranscriptSearchMatch> { + if self.matches.is_empty() { + self.active = None; + return None; + } + self.active = Some(match self.active { + Some(index) => (index + 1) % self.matches.len(), + None => 0, + }); + self.active_match() + } + + pub(crate) fn previous(&mut self) -> Option<&TranscriptSearchMatch> { + if self.matches.is_empty() { + self.active = None; + return None; + } + self.active = Some(match self.active { + Some(0) | None => self.matches.len() - 1, + Some(index) => index - 1, + }); + self.active_match() + } + + pub(crate) fn active_match(&self) -> Option<&TranscriptSearchMatch> { + self.active.and_then(|index| self.matches.get(index)) + } + + pub(crate) fn active_index(&self) -> Option { + self.active + } + + pub(crate) fn active_ordinal(&self) -> Option { + self.active.map(|index| index + 1) + } + + pub(crate) fn match_count(&self) -> usize { + self.matches.len() + } + + pub(crate) fn clear_matches(&mut self, generation: u64) { + self.matches.clear(); + self.active = None; + self.prepared_generation = Some(generation); + self.prepared_query.clone_from(&self.query); + } + + pub(crate) fn visible_matches( + &self, + start_row: usize, + end_row: usize, + ) -> impl Iterator { + self.matches + .iter() + .enumerate() + .skip_while(move |(_, found)| found.last_covered_row() < start_row) + .take_while(move |(_, found)| found.start.row < end_row) + } + + fn invalidate_prepared(&mut self) { + self.prepared_generation = None; + } + + #[cfg(test)] + pub(crate) fn scan_count_for_test(&self) -> usize { + self.scan_count + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn match_at( + row: usize, + col: usize, + revision: u64, + bytes: Range, + ) -> TranscriptSearchMatch { + TranscriptSearchMatch { + start: SelectionPos { row, col }, + end: SelectionPos { + row, + col: col + (bytes.end - bytes.start), + }, + line_identity: TranscriptLineIdentity { + message_revision: revision, + line_index: 0, + }, + byte_range: bytes, + } + } + + #[test] + fn lowercase_query_is_case_insensitive_and_uppercase_query_is_sensitive() { + let insensitive = SearchQuery::new("error"); + assert_eq!(insensitive.find_ranges("ERROR error"), vec![0..5, 6..11]); + + let sensitive = SearchQuery::new("Error"); + assert_eq!(sensitive.find_ranges("ERROR Error error"), vec![6..11]); + } + + #[test] + fn unicode_case_folding_maps_matches_back_to_original_boundaries() { + let query = SearchQuery::new("ä"); + let text = "Ärger ä"; + let ranges = query.find_ranges(text); + assert_eq!(ranges.len(), 2); + assert_eq!(&text[ranges[0].clone()], "Ä"); + assert_eq!(&text[ranges[1].clone()], "ä"); + assert!( + ranges + .iter() + .all(|range| text_boundary_or_empty(text, range)) + ); + } + + #[test] + fn repeated_matches_are_non_overlapping() { + assert_eq!(SearchQuery::new("aa").find_ranges("aaaa"), vec![0..2, 2..4]); + assert!(SearchQuery::new("").find_ranges("anything").is_empty()); + } + + #[test] + fn query_editing_uses_utf8_byte_cursor_and_paste_normalizes_lines() { + let mut search = TranscriptSearchState::default(); + search.open_new(); + search.insert_char('中'); + search.insert_char('a'); + search.move_left(); + search.insert_char('文'); + assert_eq!(search.query(), "中文a"); + assert_eq!(search.cursor(), "中文".len()); + assert!(search.backspace()); + assert_eq!(search.query(), "中a"); + search.insert_paste("one\r\ntwo\nthree"); + assert_eq!(search.query(), "中one two threea"); + } + + #[test] + fn refresh_preserves_active_identity_and_selects_nearest_following_match() { + let mut search = TranscriptSearchState::default(); + search.open_new(); + search.replace_query("hit"); + let first = match_at(2, 0, 10, 0..3); + let second = match_at(8, 0, 20, 0..3); + search.refresh_with(1, 5, |_| vec![first.clone(), second.clone()]); + assert_eq!(search.active_match(), Some(&second)); + + search.next(); + assert_eq!(search.active_match(), Some(&first)); + search.refresh_with(2, 0, |_| vec![second.clone()]); + assert_eq!(search.active_match(), Some(&second)); + } + + #[test] + fn next_and_previous_wrap_without_rescanning() { + let mut search = TranscriptSearchState::default(); + search.open_new(); + search.replace_query("x"); + search.refresh_with(1, 0, |_| { + vec![match_at(1, 0, 1, 0..1), match_at(4, 0, 2, 0..1)] + }); + let scans = search.scan_count; + assert_eq!(search.next().map(|found| found.start.row), Some(4)); + assert_eq!(search.next().map(|found| found.start.row), Some(1)); + assert_eq!(search.previous().map(|found| found.start.row), Some(4)); + assert_eq!(search.scan_count, scans); + } + + fn text_boundary_or_empty(text: &str, range: &Range) -> bool { + range.is_empty() + || (range.end <= text.len() + && text.is_char_boundary(range.start) + && text.is_char_boundary(range.end)) + } +} diff --git a/crates/orca-tui/src/transcript_view.rs b/crates/orca-tui/src/transcript_view.rs index b33ee4ff..fd41531b 100644 --- a/crates/orca-tui/src/transcript_view.rs +++ b/crates/orca-tui/src/transcript_view.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use std::cell::Cell; use std::{ collections::{BTreeSet, VecDeque}, mem, @@ -11,7 +13,9 @@ use ratatui::widgets::Paragraph; use unicode_width::UnicodeWidthStr; use crate::selection::{SelectionPos, TranscriptSelection, slice_row_by_columns}; +use crate::terminal_capabilities::TerminalColorLevel; use crate::theme::Theme; +use crate::transcript_search::{SearchQuery, TranscriptLineIdentity, TranscriptSearchMatch}; use crate::types::ChatMessage; const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -235,6 +239,11 @@ struct ThemeIdentity { approval: Color, diff_add: Color, diff_remove: Color, + markdown_h1: Color, + markdown_h2: Color, + markdown_h3: Color, + markdown_inline_code: Color, + color_level: TerminalColorLevel, } impl From<&Theme> for ThemeIdentity { @@ -250,6 +259,11 @@ impl From<&Theme> for ThemeIdentity { approval: theme.approval, diff_add: theme.diff_add, diff_remove: theme.diff_remove, + markdown_h1: theme.markdown_h1, + markdown_h2: theme.markdown_h2, + markdown_h3: theme.markdown_h3, + markdown_inline_code: theme.markdown_inline_code, + color_level: theme.color_level, } } } @@ -259,6 +273,7 @@ struct CachedMessage { revision: u64, width: usize, theme: ThemeIdentity, + syntax_theme_revision: u64, force_expand: bool, spinner_phase: Option, wrapped_lines: Vec, @@ -266,18 +281,25 @@ struct CachedMessage { visual_height: usize, } +struct SearchableLogicalLine { + text: String, + boundaries: Vec<(usize, SelectionPos)>, +} + impl CachedMessage { fn matches( &self, revision: u64, width: usize, theme: ThemeIdentity, + syntax_theme_revision: u64, force_expand: bool, spinner_phase: Option, ) -> bool { self.revision == revision && self.width == width && self.theme == theme + && self.syntax_theme_revision == syntax_theme_revision && self.force_expand == force_expand && self.spinner_phase == spinner_phase } @@ -287,6 +309,7 @@ impl CachedMessage { revision: u64, width: usize, theme: ThemeIdentity, + syntax_theme_revision: u64, force_expand: bool, spinner_phase: Option, ) -> bool { @@ -296,6 +319,7 @@ impl CachedMessage { if self.revision != revision || self.width != width || self.theme != theme + || self.syntax_theme_revision != syntax_theme_revision || self.force_expand != force_expand || self.spinner_phase.is_none() || self.spinner_phase == Some(spinner_phase) @@ -332,10 +356,41 @@ pub(crate) struct TranscriptRenderCache { spinner_indices: BTreeSet, prepared_width: Option, prepared_theme: Option, + prepared_syntax_theme_revision: Option, prepared_force_expand: Option, prepared_spinner_phase: Option, + content_generation: u64, #[cfg(test)] last_prepare_visited: usize, + #[cfg(test)] + last_search_lines_visited: Cell, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct TranscriptRenderContext<'a> { + width: usize, + theme: &'a Theme, + syntax_theme_revision: u64, + tick: u64, + force_expand: bool, +} + +impl<'a> TranscriptRenderContext<'a> { + pub(crate) fn new(theme: &'a Theme, width: usize, tick: u64, force_expand: bool) -> Self { + Self { + width, + theme, + syntax_theme_revision: theme.syntax_theme_revision, + tick, + force_expand, + } + } + + #[cfg(test)] + fn with_syntax_theme_revision(mut self, syntax_theme_revision: u64) -> Self { + self.syntax_theme_revision = syntax_theme_revision; + self + } } #[derive(Debug, Default)] @@ -376,12 +431,129 @@ impl TranscriptRenderCache { } #[cfg(test)] - fn last_prepare_visited(&self) -> usize { + pub(crate) fn last_prepare_visited(&self) -> usize { self.last_prepare_visited } + #[cfg(test)] + pub(crate) fn last_search_lines_visited(&self) -> usize { + self.last_search_lines_visited.get() + } + pub fn clear(&mut self) { - *self = Self::default(); + let next_generation = next_generation(self.content_generation); + *self = Self { + content_generation: next_generation, + ..Self::default() + }; + } + + pub(crate) fn content_generation(&self) -> u64 { + self.content_generation + } + + fn bump_content_generation(&mut self) { + self.content_generation = next_generation(self.content_generation); + } + + pub(crate) fn search( + &self, + first_retained_message: usize, + query: &SearchQuery, + ) -> Vec { + #[cfg(test)] + self.last_search_lines_visited.set(0); + if query.is_empty() { + return Vec::new(); + } + let mut matches = Vec::new(); + for message_index in first_retained_message.min(self.entries.len())..self.entries.len() { + let Some(cached) = self.entries[message_index].as_ref() else { + continue; + }; + let message_start = self + .cumulative_heights + .get(message_index) + .copied() + .unwrap_or_default(); + for (line_index, wrapped) in cached.wrapped_lines.iter().enumerate() { + #[cfg(test)] + self.last_search_lines_visited + .set(self.last_search_lines_visited.get() + 1); + let line_start = cached + .line_cumulative_heights + .get(line_index) + .copied() + .unwrap_or_default(); + let searchable = searchable_logical_line( + wrapped, + message_start.saturating_add(line_start), + line_index == 0 && cached.spinner_phase.is_some(), + ); + for byte_range in query.find_ranges(&searchable.text) { + let Ok(start_index) = searchable + .boundaries + .binary_search_by_key(&byte_range.start, |(byte, _)| *byte) + else { + continue; + }; + let Ok(end_index) = searchable + .boundaries + .binary_search_by_key(&byte_range.end, |(byte, _)| *byte) + else { + continue; + }; + matches.push(TranscriptSearchMatch::new( + searchable.boundaries[start_index].1, + searchable.boundaries[end_index].1, + TranscriptLineIdentity { + message_revision: cached.revision, + line_index, + }, + byte_range, + )); + } + } + } + matches + } + + pub(crate) fn reveal_offset( + &self, + first_retained_message: usize, + current_offset: usize, + visible_height: usize, + found: &TranscriptSearchMatch, + ) -> usize { + let live_start = first_retained_message.min(self.entries.len()); + let base_height = self + .cumulative_heights + .get(live_start) + .copied() + .unwrap_or_default(); + let absolute_total = self.cumulative_heights.last().copied().unwrap_or_default(); + let total_height = absolute_total.saturating_sub(base_height); + let max_scroll = total_height.saturating_sub(visible_height); + let current = current_offset.min(max_scroll); + if visible_height == 0 { + return current; + } + + let visible_start = base_height.saturating_add(current); + let visible_end = visible_start.saturating_add(visible_height); + let match_start = found.start.row; + let match_end = found.last_covered_row(); + let target = if match_start < visible_start { + match_start.saturating_sub(base_height) + } else if match_end >= visible_end { + match_end + .saturating_add(1) + .saturating_sub(visible_height) + .saturating_sub(base_height) + } else { + current + }; + target.min(max_scroll) } pub fn reconcile_len(&mut self, len: usize) { @@ -408,13 +580,18 @@ impl TranscriptRenderCache { } pub fn truncate(&mut self, len: usize) { + let changed = len < self.entries.len(); self.entries.truncate(len); self.dirty_indices.retain(|index| *index < len); self.spinner_indices.retain(|index| *index < len); self.rebuild_cumulative_heights(); + if changed { + self.bump_content_generation(); + } } pub fn retain(&mut self, keep: &[bool]) { + let original_len = self.entries.len(); let old_entries = mem::take(&mut self.entries); let old_dirty = mem::take(&mut self.dirty_indices); let old_spinners = mem::take(&mut self.spinner_indices); @@ -434,6 +611,9 @@ impl TranscriptRenderCache { self.entries.push(entry); } self.rebuild_cumulative_heights(); + if self.entries.len() != original_len { + self.bump_content_generation(); + } } pub fn invalidate(&mut self, index: usize) { @@ -446,24 +626,30 @@ impl TranscriptRenderCache { &mut self, messages: &[ChatMessage], revisions: &[u64], - width: usize, - theme: &Theme, - tick: u64, - force_expand: bool, + context: TranscriptRenderContext<'_>, mut build_message: F, ) where - F: FnMut(&ChatMessage, &Theme, usize, u64, bool) -> Vec>, + F: FnMut(usize, &ChatMessage, &Theme, usize, u64, bool) -> Vec>, { + let TranscriptRenderContext { + width, + theme, + syntax_theme_revision, + tick, + force_expand, + } = context; let width = width.max(1); let theme_identity = ThemeIdentity::from(theme); self.reconcile_len(messages.len()); if self.prepared_width != Some(width) || self.prepared_theme != Some(theme_identity) + || self.prepared_syntax_theme_revision != Some(syntax_theme_revision) || self.prepared_force_expand != Some(force_expand) { self.dirty_indices.extend(0..messages.len()); self.prepared_width = Some(width); self.prepared_theme = Some(theme_identity); + self.prepared_syntax_theme_revision = Some(syntax_theme_revision); self.prepared_force_expand = Some(force_expand); } let spinner_phase = ((tick / 2) % 10) as u8; @@ -474,6 +660,7 @@ impl TranscriptRenderCache { } let dirty_indices = mem::take(&mut self.dirty_indices); let mut first_height_change: Option = None; + let mut rebuilt_any = false; #[cfg(test)] { self.last_prepare_visited = 0; @@ -490,18 +677,33 @@ impl TranscriptRenderCache { let revision = revisions.get(index).copied().unwrap_or_default(); let spinner_phase = message_spinner_phase(message, tick); if self.entries[index].as_mut().is_some_and(|cached| { - cached.patch_spinner(revision, width, theme_identity, force_expand, spinner_phase) + cached.patch_spinner( + revision, + width, + theme_identity, + syntax_theme_revision, + force_expand, + spinner_phase, + ) }) { continue; } let matches = self.entries[index].as_ref().is_some_and(|cached| { - cached.matches(revision, width, theme_identity, force_expand, spinner_phase) + cached.matches( + revision, + width, + theme_identity, + syntax_theme_revision, + force_expand, + spinner_phase, + ) }); if matches { continue; } - let lines = build_message(message, theme, width, tick, force_expand); + let lines = build_message(index, message, theme, width, tick, force_expand); + rebuilt_any = true; let ratatui_width = width.min(u16::MAX as usize) as u16; let wrapped_lines = lines .iter() @@ -538,6 +740,7 @@ impl TranscriptRenderCache { revision, width, theme: theme_identity, + syntax_theme_revision, force_expand, spinner_phase, wrapped_lines, @@ -552,6 +755,9 @@ impl TranscriptRenderCache { if let Some(index) = first_height_change { self.rebuild_cumulative_heights_from(index); } + if rebuilt_any { + self.bump_content_generation(); + } } pub fn viewport( @@ -927,9 +1133,83 @@ fn message_spinner_phase(message: &ChatMessage, tick: u64) -> Option { } } +fn next_generation(current: u64) -> u64 { + current.wrapping_add(1).max(1) +} + +fn searchable_logical_line( + wrapped: &CompactWrappedLine, + absolute_first_row: usize, + exclude_spinner: bool, +) -> SearchableLogicalLine { + use unicode_width::UnicodeWidthChar; + + let mut text = String::new(); + let mut boundaries = Vec::new(); + for row_within in 0..wrapped.row_count() { + let row_start = wrapped.row_boundaries[row_within]; + let row_end = wrapped.row_boundaries[row_within + 1]; + let row = &wrapped.text[row_start..row_end]; + let absolute_row = absolute_first_row.saturating_add(row_within); + let mut col = 0usize; + for (character_index, character) in row.chars().enumerate() { + let spinner = exclude_spinner + && row_within == 0 + && character_index == 2 + && SPINNER_FRAMES + .iter() + .any(|frame| frame.starts_with(character)); + if spinner { + col = col.saturating_add(character.width().unwrap_or(0)); + continue; + } + boundaries.push(( + text.len(), + SelectionPos { + row: absolute_row, + col, + }, + )); + text.push(character); + col = col.saturating_add(character.width().unwrap_or(0)); + } + if let Some(gap) = wrapped.wrap_gaps.get(row_within) { + let next_row = absolute_row.saturating_add(1); + let mut gap_col = 0usize; + for character in gap.chars() { + boundaries.push(( + text.len(), + SelectionPos { + row: next_row, + col: gap_col, + }, + )); + text.push(character); + gap_col = gap_col.saturating_add(character.width().unwrap_or(0)); + } + } + } + let end = if let Some(last_row) = wrapped.row_count().checked_sub(1) { + let row_start = wrapped.row_boundaries[last_row]; + let row_end = wrapped.row_boundaries[last_row + 1]; + let row = &wrapped.text[row_start..row_end]; + SelectionPos { + row: absolute_first_row.saturating_add(last_row), + col: UnicodeWidthStr::width(row), + } + } else { + SelectionPos { + row: absolute_first_row, + col: 0, + } + }; + boundaries.push((text.len(), end)); + SearchableLogicalLine { text, boundaries } +} + #[cfg(test)] mod tests { - use std::cell::Cell; + use std::cell::{Cell, RefCell}; use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Rect}; @@ -938,10 +1218,16 @@ mod tests { use ratatui::widgets::{Paragraph, Widget, Wrap}; use unicode_width::UnicodeWidthStr; - use super::{TranscriptRenderCache, viewport_paragraph, wrap_line_ratatui_compatible}; + use super::{ + SPINNER_FRAMES, TranscriptRenderCache, TranscriptRenderContext, viewport_paragraph, + wrap_line_ratatui_compatible, + }; use crate::selection::{SelectionGranularity, SelectionPos, TranscriptSelection}; use crate::theme::Theme; - use crate::types::ChatMessage; + use crate::transcript_search::{ + SearchQuery, TranscriptLineIdentity, TranscriptSearchMatch, TranscriptSearchState, + }; + use crate::types::{AppState, ChatMessage, TuiEvent}; use crate::ui::build_lines_for_messages; fn theme() -> Theme { @@ -1005,11 +1291,8 @@ mod tests { cache.prepare( &messages, &revisions, - width as usize, - &theme, - 0, - false, - |_, _, _, _, _| source_lines.clone(), + TranscriptRenderContext::new(&theme, width as usize, 0, false), + |_, _, _, _, _, _| source_lines.clone(), ); let viewport = cache.viewport(0, usize::MAX, visible_height); let mut actual = Buffer::empty(expected.area); @@ -1032,11 +1315,8 @@ mod tests { cache.prepare( &messages, &revisions, - width, - &theme, - 0, - false, - |message, _, _, _, _| { + TranscriptRenderContext::new(&theme, width, 0, false), + |_, message, _, _, _, _| { let ChatMessage::System(index) = message else { unreachable!() }; @@ -1222,27 +1502,66 @@ mod tests { ); } + #[derive(Clone, Copy)] + struct RenderCounters<'a> { + message_builds: &'a Cell, + markdown_parses: &'a Cell, + } + + impl<'a> RenderCounters<'a> { + fn new(message_builds: &'a Cell, markdown_parses: &'a Cell) -> Self { + Self { + message_builds, + markdown_parses, + } + } + } + fn prepare_with_counters( cache: &mut TranscriptRenderCache, messages: &[ChatMessage], revisions: &[u64], width: usize, + syntax_theme_revision: u64, tick: u64, - message_builds: &Cell, - markdown_parses: &Cell, + counters: RenderCounters<'_>, ) { let theme = theme(); - cache.prepare( + prepare_with_theme_and_counters( + cache, messages, revisions, width, &theme, + syntax_theme_revision, tick, - false, - |message, theme, width, tick, force_expand| { - message_builds.set(message_builds.get() + 1); + counters, + ); + } + + fn prepare_with_theme_and_counters( + cache: &mut TranscriptRenderCache, + messages: &[ChatMessage], + revisions: &[u64], + width: usize, + theme: &Theme, + syntax_theme_revision: u64, + tick: u64, + counters: RenderCounters<'_>, + ) { + cache.prepare( + messages, + revisions, + TranscriptRenderContext::new(theme, width, tick, false) + .with_syntax_theme_revision(syntax_theme_revision), + |_, message, theme, width, tick, force_expand| { + counters + .message_builds + .set(counters.message_builds.get() + 1); if matches!(message, ChatMessage::Assistant(_)) { - markdown_parses.set(markdown_parses.get() + 1); + counters + .markdown_parses + .set(counters.markdown_parses.get() + 1); } build_lines_for_messages( std::slice::from_ref(message), @@ -1255,173 +1574,959 @@ mod tests { ); } - #[test] - fn scroll_only_second_frame_builds_and_parses_zero_messages() { - let messages = vec![ - ChatMessage::Assistant("# Cached\n\nMarkdown body".to_string()), - ChatMessage::User("next prompt".to_string()), - ChatMessage::Assistant("final answer".to_string()), - ]; - let revisions = vec![1, 2, 3]; - let builds = Cell::new(0); - let parses = Cell::new(0); - let mut cache = TranscriptRenderCache::default(); - - prepare_with_counters(&mut cache, &messages, &revisions, 40, 0, &builds, &parses); - let _ = cache.viewport(0, 0, 4); - assert_eq!(builds.get(), 3); - assert_eq!(parses.get(), 2); - - builds.set(0); - parses.set(0); - prepare_with_counters(&mut cache, &messages, &revisions, 40, 0, &builds, &parses); - let _ = cache.viewport(0, 2, 4); - - assert_eq!(builds.get(), 0); - assert_eq!(parses.get(), 0); - assert_eq!(cache.last_prepare_visited(), 0); + fn prepare_exact( + cache: &mut TranscriptRenderCache, + messages: &[ChatMessage], + revisions: &[u64], + width: usize, + tick: u64, + ) { + let theme = theme(); + cache.prepare( + messages, + revisions, + TranscriptRenderContext::new(&theme, width, tick, false), + |_, message, theme, width, tick, force_expand| { + build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); } #[test] - fn assistant_delta_rebuilds_only_the_final_message() { - let mut messages = vec![ - ChatMessage::User("question".to_string()), - ChatMessage::Assistant("first".to_string()), - ChatMessage::Assistant("stream".to_string()), - ]; - let mut revisions = vec![1, 2, 3]; - let builds = Cell::new(0); - let parses = Cell::new(0); + fn content_generation_changes_only_when_searchable_cache_content_changes() { + let messages = vec![ChatMessage::Assistant("alpha".to_string())]; + let mut revisions = vec![1]; let mut cache = TranscriptRenderCache::default(); + assert_eq!(cache.content_generation(), 0); - prepare_with_counters(&mut cache, &messages, &revisions, 32, 0, &builds, &parses); - builds.set(0); - parses.set(0); + prepare_exact(&mut cache, &messages, &revisions, 40, 0); + let built = cache.content_generation(); + assert_ne!(built, 0); - let ChatMessage::Assistant(text) = &mut messages[2] else { - unreachable!(); - }; - text.push_str("ing delta"); - revisions[2] = 4; - cache.invalidate(2); - prepare_with_counters(&mut cache, &messages, &revisions, 32, 0, &builds, &parses); + prepare_exact(&mut cache, &messages, &revisions, 40, 0); + let _ = cache.viewport(0, 0, 10); + assert_eq!(cache.content_generation(), built); - assert_eq!(builds.get(), 1); - assert_eq!(parses.get(), 1); + revisions[0] += 1; + cache.invalidate(0); + prepare_exact(&mut cache, &messages, &revisions, 40, 0); + assert_ne!(cache.content_generation(), built); } #[test] - fn tick_patches_running_or_receiving_spinners_without_rebuilding_messages() { - let tool = |id: &str, status: &str| ChatMessage::ToolCall { - id: id.to_string(), + fn spinner_only_patch_does_not_change_search_generation() { + let messages = vec![ChatMessage::ToolCall { + id: "running".to_string(), name: "read".to_string(), target: None, - status: status.to_string(), + status: "running".to_string(), output: None, diff: None, kind: None, expanded: false, - }; + }]; + let revisions = vec![1]; + let mut cache = TranscriptRenderCache::default(); + prepare_exact(&mut cache, &messages, &revisions, 40, 0); + let generation = cache.content_generation(); + + prepare_exact(&mut cache, &messages, &revisions, 40, 2); + + assert_eq!(cache.content_generation(), generation); + } + + #[test] + fn structural_cache_mutations_bump_generation_only_when_effective() { let messages = vec![ - ChatMessage::Assistant("stable markdown".to_string()), - tool("running", "running"), - tool("receiving", "receiving"), - tool("completed", "completed"), + ChatMessage::System("one".to_string()), + ChatMessage::System("two".to_string()), ]; - let revisions = vec![1, 2, 3, 4]; - let builds = Cell::new(0); - let parses = Cell::new(0); + let revisions = vec![1, 2]; let mut cache = TranscriptRenderCache::default(); + prepare_exact(&mut cache, &messages, &revisions, 40, 0); - prepare_with_counters(&mut cache, &messages, &revisions, 40, 0, &builds, &parses); - let before = cache.entries[1].as_ref().unwrap().wrapped_lines[0] - .text - .clone(); - builds.set(0); - parses.set(0); - prepare_with_counters(&mut cache, &messages, &revisions, 40, 2, &builds, &parses); - let after = cache.entries[1].as_ref().unwrap().wrapped_lines[0] - .text - .clone(); + let initial = cache.content_generation(); + cache.retain(&[true, true]); + assert_eq!(cache.content_generation(), initial); - assert_eq!(builds.get(), 0); - assert_eq!(parses.get(), 0); - assert_ne!(before, after); + cache.retain(&[false, true]); + let retained = cache.content_generation(); + assert_ne!(retained, initial); + + cache.truncate(0); + let truncated = cache.content_generation(); + assert_ne!(truncated, retained); + + cache.clear(); + assert_ne!(cache.content_generation(), truncated); } - #[test] - fn thousands_of_messages_render_a_bounded_viewport_window() { - let messages = (0..5_000) - .map(|index| ChatMessage::System(format!("message {index}"))) + fn prepared_search_cache( + lines_per_message: &[Vec>], + width: usize, + ) -> TranscriptRenderCache { + let messages = (0..lines_per_message.len()) + .map(|index| ChatMessage::System(index.to_string())) .collect::>(); let revisions = (1..=messages.len() as u64).collect::>(); let mut cache = TranscriptRenderCache::default(); let theme = theme(); - cache.prepare( &messages, &revisions, - 80, - &theme, - 0, - false, - |message, _, _, _, _| match message { - ChatMessage::System(text) => vec![Line::from(text.clone()), Line::from("")], - _ => unreachable!(), + TranscriptRenderContext::new(&theme, width, 0, false), + |_, message, _, _, _, _| { + let ChatMessage::System(index) = message else { + unreachable!(); + }; + lines_per_message[index.parse::().unwrap()].clone() }, ); - let viewport = cache.viewport(0, 7_000, 20); - - assert_eq!(cache.len(), 5_000); - assert!(viewport.rendered_message_count <= 12); - assert!(viewport.last_message <= viewport.first_message + 12); + cache } #[test] - fn offsets_above_u16_max_remain_representable_and_navigable() { - let messages = (0..40_000) - .map(|index| ChatMessage::System(format!("message {index}"))) - .collect::>(); - let revisions = (1..=messages.len() as u64).collect::>(); + fn search_maps_span_and_soft_wrap_matches_to_absolute_rows() { + let messages = vec![ChatMessage::System("fixture".to_string())]; + let revisions = vec![7]; let mut cache = TranscriptRenderCache::default(); let theme = theme(); - cache.prepare( &messages, &revisions, - 80, - &theme, - 0, - false, - |message, _, _, _, _| match message { - ChatMessage::System(text) => vec![Line::from(text.clone()), Line::from("")], - _ => unreachable!(), + TranscriptRenderContext::new(&theme, 6, 0, false), + |_, _, _, _, _, _| { + vec![Line::from(vec![ + Span::styled("alpha ", Style::default().fg(Color::Red)), + Span::styled("beta", Style::default().fg(Color::Blue)), + ])] }, ); - let viewport = cache.viewport(0, 70_000, 20); - assert_eq!(viewport.total_height, 80_000); - assert_eq!(viewport.scroll_offset, 70_000); - assert!(viewport.first_message > u16::MAX as usize / 2); + let matches = cache.search(0, &SearchQuery::new("alpha beta")); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].start, SelectionPos { row: 0, col: 0 }); + assert_eq!(matches[0].end, SelectionPos { row: 1, col: 4 }); + assert_eq!(matches[0].line_identity.message_revision, 7); } #[test] - fn retained_prefix_rebases_total_height_and_visible_message_indices() { - let messages = (0..100) - .map(|index| ChatMessage::System(format!("message {index}"))) - .collect::>(); - let revisions = (1..=messages.len() as u64).collect::>(); + fn search_does_not_cross_hard_lines_or_message_boundaries() { + let cache = prepared_search_cache( + &[ + vec![Line::from("alpha"), Line::from("beta")], + vec![Line::from("gamma")], + ], + 80, + ); + assert!(cache.search(0, &SearchQuery::new("alpha beta")).is_empty()); + assert!(cache.search(0, &SearchQuery::new("beta gamma")).is_empty()); + } + + #[test] + fn search_maps_cjk_combining_and_emoji_to_display_columns() { + let cache = prepared_search_cache(&[vec![Line::from("A中e\u{301}👍🏽Z")]], 80); + let cjk = cache.search(0, &SearchQuery::new("中")); + assert_eq!((cjk[0].start.col, cjk[0].end.col), (1, 3)); + let combining = cache.search(0, &SearchQuery::new("e\u{301}")); + assert_eq!((combining[0].start.col, combining[0].end.col), (3, 4)); + let emoji = cache.search(0, &SearchQuery::new("👍🏽")); + assert!(emoji[0].end.col > emoji[0].start.col); + } + + #[test] + fn search_skips_unprepared_and_flushed_entries_and_keeps_repeated_matches() { + let mut unprepared = TranscriptRenderCache::default(); + unprepared.reconcile_len(1); + assert!(unprepared.search(0, &SearchQuery::new("x")).is_empty()); + + let repeated = prepared_search_cache(&[vec![Line::from("x x x")]], 80); + assert_eq!(repeated.search(0, &SearchQuery::new("x")).len(), 3); + + let flushed = prepared_search_cache( + &[ + vec![Line::from("old target")], + vec![Line::from("live target")], + ], + 80, + ); + let found = flushed.search(1, &SearchQuery::new("target")); + assert_eq!(found.len(), 1); + assert_eq!(found[0].line_identity.message_revision, 2); + } + + #[test] + fn search_coordinates_remain_usize_above_u16_max() { + let lines = (0..70_000) + .map(|index| Line::from(format!("line {index}"))) + .collect::>(); + let cache = prepared_search_cache(&[lines], 80); + let found = cache.search(0, &SearchQuery::new("line 69999")); + assert_eq!(found.len(), 1); + assert_eq!(found[0].start.row, 69_999); + } + + #[test] + fn search_excludes_spinner_glyph_but_keeps_stable_tool_text() { + let messages = vec![ChatMessage::ToolCall { + id: "running".to_string(), + name: "read".to_string(), + target: Some("src/lib.rs".to_string()), + status: "running".to_string(), + output: None, + diff: None, + kind: None, + expanded: false, + }]; + let revisions = vec![1]; + let mut cache = TranscriptRenderCache::default(); + prepare_exact(&mut cache, &messages, &revisions, 80, 0); + + assert!( + cache + .search(0, &SearchQuery::new(SPINNER_FRAMES[0])) + .is_empty() + ); + assert_eq!(cache.search(0, &SearchQuery::new("read")).len(), 1); + assert_eq!(cache.search(0, &SearchQuery::new("running")).len(), 1); + } + + #[test] + fn reveal_offset_keeps_visible_match_and_uses_nearest_edge() { + let cache = prepared_search_cache( + &(0..30) + .map(|index| vec![Line::from(format!("line {index}"))]) + .collect::>(), + 80, + ); + let found = |row, revision| { + TranscriptSearchMatch::new( + SelectionPos { row, col: 0 }, + SelectionPos { row, col: 4 }, + TranscriptLineIdentity { + message_revision: revision, + line_index: 0, + }, + 0..4, + ) + }; + + assert_eq!(cache.reveal_offset(0, 10, 5, &found(12, 13)), 10); + assert_eq!(cache.reveal_offset(0, 10, 5, &found(3, 4)), 3); + assert_eq!(cache.reveal_offset(0, 10, 5, &found(20, 21)), 16); + } + + #[test] + fn scroll_only_second_frame_builds_and_parses_zero_messages() { + let messages = vec![ + ChatMessage::Assistant("# Cached\n\nMarkdown body".to_string()), + ChatMessage::User("next prompt".to_string()), + ChatMessage::Assistant("final answer".to_string()), + ]; + let revisions = vec![1, 2, 3]; + let builds = Cell::new(0); + let parses = Cell::new(0); + let mut cache = TranscriptRenderCache::default(); + + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); + let _ = cache.viewport(0, 0, 4); + assert_eq!(builds.get(), 3); + assert_eq!(parses.get(), 2); + + builds.set(0); + parses.set(0); + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); + let _ = cache.viewport(0, 2, 4); + + assert_eq!(builds.get(), 0); + assert_eq!(parses.get(), 0); + assert_eq!(cache.last_prepare_visited(), 0); + } + + #[test] + fn assistant_delta_rebuilds_only_the_final_message() { + let mut messages = vec![ + ChatMessage::User("question".to_string()), + ChatMessage::Assistant("first".to_string()), + ChatMessage::Assistant("stream".to_string()), + ]; + let mut revisions = vec![1, 2, 3]; + let builds = Cell::new(0); + let parses = Cell::new(0); + let mut cache = TranscriptRenderCache::default(); + + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 32, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); + builds.set(0); + parses.set(0); + + let ChatMessage::Assistant(text) = &mut messages[2] else { + unreachable!(); + }; + text.push_str("ing delta"); + revisions[2] = 4; + cache.invalidate(2); + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 32, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); + + assert_eq!(builds.get(), 1); + assert_eq!(parses.get(), 1); + } + + #[test] + fn markdown_theme_color_change_rebuilds_wrapped_lines_once() { + let messages = vec![ChatMessage::Assistant( + "# Heading\n\nUse `cargo test`.".to_string(), + )]; + let revisions = vec![1]; + let builds = Cell::new(0); + let parses = Cell::new(0); let mut cache = TranscriptRenderCache::default(); let theme = theme(); - cache.prepare( + prepare_with_theme_and_counters( + &mut cache, &messages, &revisions, - 80, + 40, + &theme, + theme.syntax_theme_revision, + 0, + RenderCounters::new(&builds, &parses), + ); + builds.set(0); + parses.set(0); + + let mut changed = theme; + changed.markdown_inline_code = Color::Rgb(1, 2, 3); + prepare_with_theme_and_counters( + &mut cache, + &messages, + &revisions, + 40, + &changed, + changed.syntax_theme_revision, + 0, + RenderCounters::new(&builds, &parses), + ); + assert_eq!(builds.get(), 1); + assert_eq!(parses.get(), 1); + + builds.set(0); + parses.set(0); + prepare_with_theme_and_counters( + &mut cache, + &messages, + &revisions, + 40, + &changed, + changed.syntax_theme_revision, + 0, + RenderCounters::new(&builds, &parses), + ); + assert_eq!(builds.get(), 0); + assert_eq!(parses.get(), 0); + assert_eq!(cache.last_prepare_visited(), 0); + } + + #[test] + fn color_level_identity_rebuilds_wrapped_lines_once() { + use crate::terminal_capabilities::TerminalColorLevel; + + let messages = vec![ChatMessage::Assistant("cached body".to_string())]; + let revisions = vec![1]; + let builds = Cell::new(0); + let parses = Cell::new(0); + let mut cache = TranscriptRenderCache::default(); + let theme = theme(); + + prepare_with_theme_and_counters( + &mut cache, + &messages, + &revisions, + 40, &theme, + theme.syntax_theme_revision, + 0, + RenderCounters::new(&builds, &parses), + ); + builds.set(0); + parses.set(0); + + let mut changed = theme; + changed.color_level = TerminalColorLevel::Ansi256; + prepare_with_theme_and_counters( + &mut cache, + &messages, + &revisions, + 40, + &changed, + changed.syntax_theme_revision, + 0, + RenderCounters::new(&builds, &parses), + ); + assert_eq!(builds.get(), 1); + assert_eq!(parses.get(), 1); + + builds.set(0); + parses.set(0); + prepare_with_theme_and_counters( + &mut cache, + &messages, + &revisions, + 40, + &changed, + changed.syntax_theme_revision, + 0, + RenderCounters::new(&builds, &parses), + ); + assert_eq!(builds.get(), 0); + assert_eq!(parses.get(), 0); + assert_eq!(cache.last_prepare_visited(), 0); + } + + #[test] + fn syntax_theme_revision_rebuilds_highlighted_wrapped_lines_once() { + let messages = vec![ChatMessage::Assistant( + "```rust\nfn highlighted() {}\n```".to_string(), + )]; + let revisions = vec![1]; + let builds = Cell::new(0); + let parses = Cell::new(0); + let mut cache = TranscriptRenderCache::default(); + + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); + builds.set(0); + parses.set(0); + + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); + assert_eq!(builds.get(), 0); + assert_eq!(parses.get(), 0); + + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 2, + 0, + RenderCounters::new(&builds, &parses), + ); + assert_eq!(builds.get(), 1); + assert_eq!(parses.get(), 1); + + builds.set(0); + parses.set(0); + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 2, 0, - false, - |message, _, _, _, _| match message { + RenderCounters::new(&builds, &parses), + ); + assert_eq!(builds.get(), 0); + assert_eq!(parses.get(), 0); + assert_eq!(cache.last_prepare_visited(), 0); + } + + #[test] + fn spinner_frames_preserve_patch_byte_length_and_display_width() { + let expected_byte_length = SPINNER_FRAMES[0].len(); + + for frame in SPINNER_FRAMES { + assert_eq!(frame.len(), expected_byte_length); + assert_eq!(frame.width(), 1); + } + } + + #[test] + fn spinner_patch_requires_matching_syntax_theme_revision() { + let messages = vec![ChatMessage::ToolCall { + id: "running".to_string(), + name: "read".to_string(), + target: None, + status: "running".to_string(), + output: None, + diff: None, + kind: None, + expanded: false, + }]; + let revisions = vec![1]; + let builds = Cell::new(0); + let parses = Cell::new(0); + let mut cache = TranscriptRenderCache::default(); + + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); + builds.set(0); + parses.set(0); + + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 2, + 2, + RenderCounters::new(&builds, &parses), + ); + + assert_eq!(builds.get(), 1); + assert_eq!(parses.get(), 0); + } + + #[test] + fn builder_receives_initial_and_selectively_invalidated_message_indices() { + let messages = vec![ + ChatMessage::System("zero".to_string()), + ChatMessage::System("one".to_string()), + ChatMessage::System("two".to_string()), + ]; + let mut revisions = vec![1, 2, 3]; + let built_indices = RefCell::new(Vec::new()); + let mut cache = TranscriptRenderCache::default(); + let theme = theme(); + + cache.prepare( + &messages, + &revisions, + TranscriptRenderContext::new(&theme, 40, 0, false).with_syntax_theme_revision(1), + |index, message, _, _, _, _| { + built_indices.borrow_mut().push(index); + vec![Line::from(format!("{message:?}"))] + }, + ); + assert_eq!(*built_indices.borrow(), vec![0, 1, 2]); + assert_eq!(cache.last_prepare_visited(), messages.len()); + + built_indices.borrow_mut().clear(); + revisions[1] += 1; + cache.invalidate(1); + cache.prepare( + &messages, + &revisions, + TranscriptRenderContext::new(&theme, 40, 0, false).with_syntax_theme_revision(1), + |index, message, _, _, _, _| { + built_indices.borrow_mut().push(index); + vec![Line::from(format!("{message:?}"))] + }, + ); + + assert_eq!(*built_indices.borrow(), vec![1]); + assert_eq!(cache.last_prepare_visited(), 1); + } + + #[test] + fn ten_thousand_messages_search_once_then_steady_scroll_and_navigation_scan_zero() { + let messages = (0..10_000) + .map(|index| ChatMessage::System(format!("message {index} needle"))) + .collect::>(); + let revisions = (1..=10_000).collect::>(); + let mut cache = TranscriptRenderCache::default(); + let theme = theme(); + cache.prepare( + &messages, + &revisions, + TranscriptRenderContext::new(&theme, 80, 0, false), + |_, message, _, _, _, _| match message { + ChatMessage::System(text) => vec![Line::from(text.clone())], + _ => unreachable!(), + }, + ); + + let mut search = TranscriptSearchState::default(); + search.open_new(); + search.replace_query("needle"); + search.refresh_with(cache.content_generation(), 0, |query| { + cache.search(0, query) + }); + assert_eq!(search.match_count(), 10_000); + assert_eq!(cache.last_search_lines_visited(), 10_000); + + search.refresh_with(cache.content_generation(), 0, |_| unreachable!()); + let _ = cache.viewport(0, 5_000, 20); + search.refresh_with(cache.content_generation(), 5_000, |_| unreachable!()); + search.next(); + search.previous(); + assert_eq!(search.scan_count_for_test(), 1); + } + + #[test] + fn thousand_streaming_blocks_rebuild_only_the_active_tail() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut state = AppState::new( + tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + for index in 0..1_000 { + state.update(TuiEvent::MessageDelta(format!("block {index}\n\n"))); + } + + assert_eq!(state.messages.len(), 1_000); + assert!( + state + .messages + .iter() + .all(|message| matches!(message, ChatMessage::AssistantChunk { .. })) + ); + let frozen_revisions = state.message_revisions.clone(); + let theme = theme(); + let built_indices = RefCell::new(Vec::new()); + let rebuilt_text_bytes = Cell::new(0); + state.transcript_render_cache.prepare( + &state.messages, + &state.message_revisions, + TranscriptRenderContext::new(&theme, 80, 0, false).with_syntax_theme_revision(1), + |index, message, theme, width, tick, force_expand| { + built_indices.borrow_mut().push(index); + if let ChatMessage::Assistant(text) | ChatMessage::AssistantChunk { text, .. } = + message + { + rebuilt_text_bytes.set(rebuilt_text_bytes.get() + text.len()); + } + build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 1_000); + + built_indices.borrow_mut().clear(); + rebuilt_text_bytes.set(0); + state.update(TuiEvent::MessageDelta("live tail\n".to_string())); + state.transcript_render_cache.prepare( + &state.messages, + &state.message_revisions, + TranscriptRenderContext::new(&theme, 80, 0, false).with_syntax_theme_revision(1), + |index, message, theme, width, tick, force_expand| { + built_indices.borrow_mut().push(index); + if let ChatMessage::Assistant(text) | ChatMessage::AssistantChunk { text, .. } = + message + { + rebuilt_text_bytes.set(rebuilt_text_bytes.get() + text.len()); + } + build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); + + assert_eq!(*built_indices.borrow(), vec![1_000]); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 1); + assert_eq!(rebuilt_text_bytes.get(), "live tail\n".len()); + assert_eq!( + &state.message_revisions[..1_000], + frozen_revisions.as_slice() + ); + + built_indices.borrow_mut().clear(); + state.transcript_render_cache.prepare( + &state.messages, + &state.message_revisions, + TranscriptRenderContext::new(&theme, 80, 0, false).with_syntax_theme_revision(1), + |index, message, theme, width, tick, force_expand| { + built_indices.borrow_mut().push(index); + build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); + assert!(built_indices.borrow().is_empty()); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 0); + + let _ = state.transcript_render_cache.viewport(0, 500, 20); + state.transcript_render_cache.prepare( + &state.messages, + &state.message_revisions, + TranscriptRenderContext::new(&theme, 80, 0, false).with_syntax_theme_revision(1), + |index, message, theme, width, tick, force_expand| { + built_indices.borrow_mut().push(index); + build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); + assert!(built_indices.borrow().is_empty()); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 0); + + for (width, theme, syntax_revision, force_expand) in [ + (79, theme, 1, false), + ( + 79, + Theme::named(orca_core::config::ThemeName::Light), + 1, + false, + ), + ( + 79, + Theme::named(orca_core::config::ThemeName::Light), + 2, + false, + ), + ( + 79, + Theme::named(orca_core::config::ThemeName::Light), + 2, + true, + ), + ] { + built_indices.borrow_mut().clear(); + state.transcript_render_cache.prepare( + &state.messages, + &state.message_revisions, + TranscriptRenderContext::new(&theme, width, 0, force_expand) + .with_syntax_theme_revision(syntax_revision), + |index, message, theme, width, tick, force_expand| { + built_indices.borrow_mut().push(index); + build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); + assert_eq!(built_indices.borrow().len(), 1_001); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 1_001); + } + } + + #[test] + fn assistant_chunk_selection_preserves_checkpoint_spacing_and_code() { + let messages = vec![ + ChatMessage::AssistantChunk { + text: "first paragraph\n\n".to_string(), + trailing_blank: true, + }, + ChatMessage::AssistantChunk { + text: "```rust\nfn main() {}\n```\n".to_string(), + trailing_blank: false, + }, + ChatMessage::Assistant("tail".to_string()), + ]; + let revisions = vec![1, 2, 3]; + let mut cache = TranscriptRenderCache::default(); + let theme = theme(); + cache.prepare( + &messages, + &revisions, + TranscriptRenderContext::new(&theme, 80, 0, false), + |_, message, theme, width, tick, force_expand| { + build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); + + assert_eq!( + cache.extract_text(&selection((0, 0), (99, 99))), + "first paragraph\n\n fn main() {}\ntail\n" + ); + } + + #[test] + fn tick_patches_running_or_receiving_spinners_without_rebuilding_messages() { + let tool = |id: &str, status: &str| ChatMessage::ToolCall { + id: id.to_string(), + name: "read".to_string(), + target: None, + status: status.to_string(), + output: None, + diff: None, + kind: None, + expanded: false, + }; + let messages = vec![ + ChatMessage::Assistant("stable markdown".to_string()), + tool("running", "running"), + tool("receiving", "receiving"), + tool("completed", "completed"), + ]; + let revisions = vec![1, 2, 3, 4]; + let builds = Cell::new(0); + let parses = Cell::new(0); + let mut cache = TranscriptRenderCache::default(); + + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); + let before = cache.entries[1].as_ref().unwrap().wrapped_lines[0] + .text + .clone(); + builds.set(0); + parses.set(0); + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 40, + 1, + 2, + RenderCounters::new(&builds, &parses), + ); + let after = cache.entries[1].as_ref().unwrap().wrapped_lines[0] + .text + .clone(); + + assert_eq!(builds.get(), 0); + assert_eq!(parses.get(), 0); + assert_ne!(before, after); + } + + #[test] + fn thousands_of_messages_render_a_bounded_viewport_window() { + let messages = (0..5_000) + .map(|index| ChatMessage::System(format!("message {index}"))) + .collect::>(); + let revisions = (1..=messages.len() as u64).collect::>(); + let mut cache = TranscriptRenderCache::default(); + let theme = theme(); + + cache.prepare( + &messages, + &revisions, + TranscriptRenderContext::new(&theme, 80, 0, false), + |_, message, _, _, _, _| match message { + ChatMessage::System(text) => vec![Line::from(text.clone()), Line::from("")], + _ => unreachable!(), + }, + ); + let viewport = cache.viewport(0, 7_000, 20); + + assert_eq!(cache.len(), 5_000); + assert!(viewport.rendered_message_count <= 12); + assert!(viewport.last_message <= viewport.first_message + 12); + } + + #[test] + fn offsets_above_u16_max_remain_representable_and_navigable() { + let messages = (0..40_000) + .map(|index| ChatMessage::System(format!("message {index}"))) + .collect::>(); + let revisions = (1..=messages.len() as u64).collect::>(); + let mut cache = TranscriptRenderCache::default(); + let theme = theme(); + + cache.prepare( + &messages, + &revisions, + TranscriptRenderContext::new(&theme, 80, 0, false), + |_, message, _, _, _, _| match message { + ChatMessage::System(text) => vec![Line::from(text.clone()), Line::from("")], + _ => unreachable!(), + }, + ); + let viewport = cache.viewport(0, 70_000, 20); + + assert_eq!(viewport.total_height, 80_000); + assert_eq!(viewport.scroll_offset, 70_000); + assert!(viewport.first_message > u16::MAX as usize / 2); + } + + #[test] + fn retained_prefix_rebases_total_height_and_visible_message_indices() { + let messages = (0..100) + .map(|index| ChatMessage::System(format!("message {index}"))) + .collect::>(); + let revisions = (1..=messages.len() as u64).collect::>(); + let mut cache = TranscriptRenderCache::default(); + let theme = theme(); + + cache.prepare( + &messages, + &revisions, + TranscriptRenderContext::new(&theme, 80, 0, false), + |_, message, _, _, _, _| match message { ChatMessage::System(text) => vec![Line::from(text.clone())], _ => unreachable!(), }, @@ -1448,11 +2553,8 @@ mod tests { cache.prepare( &messages, &revisions, - 80, - &theme, - 0, - false, - |message, _, _, _, _| match message { + TranscriptRenderContext::new(&theme, 80, 0, false), + |_, message, _, _, _, _| match message { ChatMessage::System(text) if text == "tall" => (0..70_000) .map(|index| Line::from(format!("line {index}"))) .collect(), @@ -1477,11 +2579,8 @@ mod tests { cache.prepare( &messages, &revisions, - 80, - &theme, - 0, - false, - |_, _, _, _, _| { + TranscriptRenderContext::new(&theme, 80, 0, false), + |_, _, _, _, _, _| { (0..70_000) .map(|index| Line::from(format!("line {index}"))) .collect() @@ -1507,11 +2606,8 @@ mod tests { cache.prepare( &messages, &revisions, - 5, - &theme, - 0, - false, - |message, _, _, _, _| match message { + TranscriptRenderContext::new(&theme, 5, 0, false), + |_, message, _, _, _, _| match message { ChatMessage::System(text) if text == "wrapped" => { vec![Line::from("abcdefghijklmnopqrstuvwxy")] } @@ -1539,11 +2635,8 @@ mod tests { cache.prepare( &messages, &revisions, - 1, - &theme, - 0, - false, - |_, _, _, _, _| vec![Line::from(body.clone())], + TranscriptRenderContext::new(&theme, 1, 0, false), + |_, _, _, _, _, _| vec![Line::from(body.clone())], ); let viewport = cache.viewport(0, 69_980, 20); @@ -1563,11 +2656,8 @@ mod tests { cache.prepare( &messages, &revisions, - 1, - &theme, - 0, - false, - |_, _, _, _, _| vec![Line::from(body.clone())], + TranscriptRenderContext::new(&theme, 1, 0, false), + |_, _, _, _, _, _| vec![Line::from(body.clone())], ); let viewport = cache.viewport(0, 0, 20); let materialized_width = viewport @@ -1595,12 +2685,28 @@ mod tests { let parses = Cell::new(0); let mut cache = TranscriptRenderCache::default(); - prepare_with_counters(&mut cache, &messages, &revisions, 80, 0, &builds, &parses); + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 80, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); let wide_height = cache.viewport(0, usize::MAX, 5).total_height; builds.set(0); parses.set(0); - prepare_with_counters(&mut cache, &messages, &revisions, 20, 0, &builds, &parses); + prepare_with_counters( + &mut cache, + &messages, + &revisions, + 20, + 1, + 0, + RenderCounters::new(&builds, &parses), + ); let narrow = cache.viewport(0, usize::MAX, 5); assert_eq!(builds.get(), 1); diff --git a/crates/orca-tui/src/types.rs b/crates/orca-tui/src/types.rs index 0e59547e..b96d6e06 100644 --- a/crates/orca-tui/src/types.rs +++ b/crates/orca-tui/src/types.rs @@ -1,10 +1,12 @@ use crossbeam_channel as mpsc; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; +use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Instant; use orca_core::approval_types::ApprovalMode; use orca_core::cancel::OperationId; +use orca_core::config::{ProviderKind, RunConfig, ThemeName}; use orca_core::cost_types::UsageTotals; use orca_core::goal_types::ThreadGoal; use orca_core::plan_types::PlanItem; @@ -16,8 +18,27 @@ use orca_runtime::mentions::{MentionBindings, MentionCandidate}; use orca_runtime::runtime_pending_interaction::RuntimeMcpElicitationMode; use orca_runtime::runtime_permission::RuntimePermissionRequestKind; +use crate::diagnostics::{ + DiagnosticRuntimeView, DiagnosticSnapshot, FrameMetrics, KeybindingsDiagnostic, + format_doctor_report, +}; use crate::display_text::truncate_to_display_width; +#[cfg(test)] +use crate::edit_highlight_worker::DrainResults; +use crate::edit_highlight_worker::{ + EditHighlightJob, EditHighlightOutcome, EditHighlightResult, EditHighlightRuntime, +}; +use crate::onboarding::OnboardingState; +use crate::queued_input::{QueuedComposerState, QueuedUserMessage}; +use crate::streaming_markdown::{StreamingMarkdownAction, StreamingMarkdownAssembler}; +use crate::syntax_highlight::{SyntaxTheme, highlighter_for_path}; +use crate::terminal_capabilities::{TerminalColorLevel, syntax_style_revision}; +use crate::transcript_search::TranscriptSearchState; use crate::transcript_view::TranscriptRenderCache; +#[cfg(test)] +use crate::transcript_view::TranscriptRenderContext; +use crate::vim::{VimMode, VimState}; +use crate::workspace_status::GitIdentity; const SUBAGENT_ACTIVITY_TAIL_LIMIT: usize = 6; const GOAL_NOTICE_OBJECTIVE_WIDTH: usize = 80; @@ -114,6 +135,88 @@ fn format_goal_notice(goal: &orca_core::goal_types::ThreadGoal) -> String { parts.join(" · ") } +fn normalize_diff_relative_path(path: &str) -> Option { + let path = Path::new(path); + if path.is_absolute() { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(component) => normalized.push(component), + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return None; + } + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + (!normalized.as_os_str().is_empty()).then_some(normalized) +} + +fn unified_diff_header_path(header: &str) -> &str { + let path = header.split_once('\t').map_or(header, |(path, _)| path); + path.strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path) +} + +fn parsed_diff_structure_matches_target( + parsed: &crate::diff_highlight::ParsedDiff, + diff: &str, + target: &Path, +) -> bool { + if !parsed.is_structurally_valid() || parsed.has_multiple_files { + return false; + } + let mut lines = diff.lines(); + let Some(old_header) = lines.next().and_then(|line| line.strip_prefix("--- ")) else { + return false; + }; + let Some(new_header) = lines.next().and_then(|line| line.strip_prefix("+++ ")) else { + return false; + }; + let old_path = unified_diff_header_path(old_header); + let new_path = unified_diff_header_path(new_header); + let old_is_null = old_path == "/dev/null"; + let new_is_null = new_path == "/dev/null"; + if old_is_null && new_is_null { + return false; + } + if (!old_is_null && normalize_diff_relative_path(old_path).is_none()) + || (!new_is_null && normalize_diff_relative_path(new_path).as_deref() != Some(target)) + || (new_is_null && normalize_diff_relative_path(old_path).as_deref() != Some(target)) + { + return false; + } + parsed + .destination_path + .as_deref() + .and_then(normalize_diff_relative_path) + .as_deref() + == Some(target) +} + +fn parsed_diff_has_valid_new_side(parsed: &crate::diff_highlight::ParsedDiff) -> bool { + let mut new_side_lines = parsed + .hunks + .iter() + .flat_map(|hunk| hunk.source_lines()) + .filter(|line| { + matches!( + line.kind, + crate::diff_highlight::DiffLineKind::Context + | crate::diff_highlight::DiffLineKind::Insert + ) + }); + new_side_lines + .next() + .is_some_and(|line| line.new_line.is_some_and(|line_number| line_number > 0)) + && new_side_lines.all(|line| line.new_line.is_some_and(|line_number| line_number > 0)) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TuiTaskLifecycle { pub id: String, @@ -197,6 +300,9 @@ pub enum TuiEvent { turn: u32, task: Option, }, + QueuedSubmissionStarted { + id: u64, + }, ReasoningDelta(String), MessageDelta(String), ToolRequested { @@ -288,6 +394,7 @@ pub enum TuiEvent { generation: u64, }, SubmissionRejected { + queued_id: Option, prompt: String, message: String, }, @@ -325,6 +432,11 @@ pub enum UserAction { prompt: String, bindings: MentionBindings, }, + SubmitQueued { + id: u64, + prompt: String, + bindings: MentionBindings, + }, SubmitWorkflowNotification(PendingWorkflowNotification), RunWorkflow { name: String, @@ -359,7 +471,7 @@ pub enum UserAction { Cancel, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppStatus { Setup, SessionPicker, @@ -375,6 +487,10 @@ pub enum ChatMessage { User(String), Reasoning(String), Assistant(String), + AssistantChunk { + text: String, + trailing_blank: bool, + }, ProposedPlan(String), ToolCall { id: String, @@ -526,7 +642,7 @@ impl ApprovalDialog { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub enum PanelMode { Conversation, Workflows, @@ -587,11 +703,25 @@ impl MentionPopupState { } } +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) struct AppliedDiffHighlight { + pub(crate) tool_id: String, + pub(crate) display_path: String, + pub(crate) styles: Arc, +} + +#[cfg(test)] +type EditHighlightRuntimeFactory = fn() -> std::io::Result; + +#[cfg(test)] +type EditHighlightDrain = fn(&mut EditHighlightRuntime) -> DrainResults; + pub struct AppState { pub(crate) messages: Vec, pub(crate) message_revisions: Vec, next_message_revision: u64, pub(crate) transcript_render_cache: TranscriptRenderCache, + pub(crate) transcript_search: TranscriptSearchState, /// Separate cache for the welcome screen (no messages yet), so its text /// is selectable/copyable through the same machinery as the transcript. pub(crate) welcome_render_cache: TranscriptRenderCache, @@ -604,6 +734,12 @@ pub struct AppState { /// behavior and tests that exercise finalized/live suffix boundaries. pub flushed_count: usize, pub status: AppStatus, + pub(crate) diagnostics: DiagnosticSnapshot, + pub(crate) frame_metrics: FrameMetrics, + pub(crate) fps_hud_enabled: bool, + pub(crate) vim_mode: Option, + pub(crate) keybindings_diagnostic: KeybindingsDiagnostic, + pub(crate) auth_configured: bool, pub running_started_at: Option, pub scroll_offset: usize, pub auto_scroll: bool, @@ -614,6 +750,7 @@ pub struct AppState { pub reasoning_effort: orca_core::config::ReasoningEffort, pub approval_mode: ApprovalMode, pub cwd: String, + pub(crate) workspace_git: Option, #[allow(dead_code)] pub event_tx: mpsc::Sender, pub approval_dialog: Option, @@ -621,10 +758,17 @@ pub struct AppState { /// Tool / "tool\u{0}target" keys the user chose to always allow this /// session. Checked when a new approval arrives so the dialog is skipped. pub approval_allowlist: std::collections::HashSet, - pub setup_step: u8, + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) onboarding: OnboardingState, pub show_shortcuts: bool, + pub(crate) keymap: std::sync::Arc, pub input_history: Vec, pub(crate) pending_pastes: Vec<(String, String)>, + pub(crate) queued_user_messages: VecDeque, + pub(crate) queued_submission_in_flight: Option, + pub(crate) queued_follow_up_autosend: bool, + pub(crate) queued_input_error: Option, + next_queued_submission_id: u64, pub history_cursor: Option, pub draft_before_history: Option, pub last_ctrl_c: Option, @@ -640,6 +784,8 @@ pub struct AppState { pub mention_bindings: MentionBindings, pub current_plan: Option<(Option, Vec)>, proposed_plan_parser: ProposedPlanStreamParser, + assistant_stream: StreamingMarkdownAssembler, + assistant_stream_tail: Option, /// The most recent update_plan call failed, so `current_plan` may be /// showing outdated statuses. Cleared by the next successful update. pub plan_update_failed: bool, @@ -676,6 +822,16 @@ pub struct AppState { /// Composer (input box) outer rect from the last render, `None` while /// the composer is hidden. pub input_area: Option, + pub(crate) search_area: Option, + workspace_root: Option, + pub(crate) syntax_theme: SyntaxTheme, + pub(crate) syntax_color_level: TerminalColorLevel, + edit_highlight_runtime: Option, + pub(crate) applied_diff_highlights: HashMap, + #[cfg(test)] + edit_highlight_runtime_factory: EditHighlightRuntimeFactory, + #[cfg(test)] + edit_highlight_drain: Option, /// A mouse drag is adjusting the composer's own text selection. pub composer_mouse_selecting: bool, /// Messages that arrived below the viewport while auto-follow was @@ -735,10 +891,17 @@ impl AppState { message_revisions: Vec::new(), next_message_revision: 1, transcript_render_cache: TranscriptRenderCache::default(), + transcript_search: TranscriptSearchState::default(), welcome_render_cache: TranscriptRenderCache::default(), finalized_count: 0, flushed_count: 0, status: AppStatus::Idle, + diagnostics: DiagnosticSnapshot::default(), + frame_metrics: FrameMetrics::default(), + fps_hud_enabled: false, + vim_mode: None, + keybindings_diagnostic: KeybindingsDiagnostic::default(), + auth_configured: false, running_started_at: None, scroll_offset: 0, auto_scroll: true, @@ -749,14 +912,25 @@ impl AppState { reasoning_effort: orca_core::config::ReasoningEffort::default(), approval_mode: ApprovalMode::default(), cwd, + workspace_git: None, event_tx, approval_dialog: None, pending_input: None, approval_allowlist: std::collections::HashSet::new(), - setup_step: 0, + onboarding: OnboardingState::new( + ProviderKind::DeepSeek, + orca_core::model::AUTO_MODEL, + ThemeName::Auto, + ), show_shortcuts: false, + keymap: crate::keybindings::Keymap::built_in(), input_history: load_input_history(), pending_pastes: Vec::new(), + queued_user_messages: VecDeque::new(), + queued_submission_in_flight: None, + queued_follow_up_autosend: true, + queued_input_error: None, + next_queued_submission_id: 1, history_cursor: None, draft_before_history: None, last_ctrl_c: None, @@ -772,6 +946,8 @@ impl AppState { mention_bindings: MentionBindings::default(), current_plan: None, proposed_plan_parser: ProposedPlanStreamParser::default(), + assistant_stream: StreamingMarkdownAssembler::default(), + assistant_stream_tail: None, plan_update_failed: false, current_goal: None, panel_mode: PanelMode::Conversation, @@ -789,11 +965,424 @@ impl AppState { jump_to_bottom_area: None, frame_area: None, input_area: None, + search_area: None, + workspace_root: None, + syntax_theme: SyntaxTheme::OneHalfDark, + syntax_color_level: TerminalColorLevel::TrueColor, + edit_highlight_runtime: None, + applied_diff_highlights: HashMap::new(), + #[cfg(test)] + edit_highlight_runtime_factory: EditHighlightRuntime::new, + #[cfg(test)] + edit_highlight_drain: None, composer_mouse_selecting: false, unseen_messages: 0, } } + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn initialize_onboarding(&mut self, config: &RunConfig) { + self.onboarding = + OnboardingState::new(config.provider, config.model.display_name(), config.theme); + } + + pub(crate) fn doctor_report(&self, now: Instant) -> String { + format_doctor_report( + &self.diagnostics, + DiagnosticRuntimeView { + viewport: self.frame_area.map(|area| (area.width, area.height)), + status: self.status, + panel: self.panel_mode, + vim_mode: self.vim_mode, + fps_hud_enabled: self.fps_hud_enabled, + keybindings: self.keybindings_diagnostic, + auth_configured: self.auth_configured, + }, + self.frame_metrics.snapshot(now), + ) + } + + pub(crate) fn set_fps_hud(&mut self, enabled: bool) { + self.fps_hud_enabled = enabled; + } + + pub(crate) fn toggle_fps_hud(&mut self) -> bool { + self.fps_hud_enabled = !self.fps_hud_enabled; + self.fps_hud_enabled + } + + pub(crate) fn sync_vim_mode(&mut self, vim_state: &VimState) { + self.vim_mode = vim_state.enabled.then_some(vim_state.mode); + } + + pub(crate) fn configure_syntax_highlighting( + &mut self, + workspace_root: PathBuf, + syntax_theme: SyntaxTheme, + syntax_color_level: TerminalColorLevel, + ) { + self.workspace_root = Some(workspace_root); + self.syntax_theme = syntax_theme; + self.syntax_color_level = syntax_color_level; + self.edit_highlight_runtime = None; + self.applied_diff_highlights.clear(); + } + + pub(crate) fn apply_theme_projection(&mut self, theme: &crate::theme::Theme) { + self.syntax_theme = theme.syntax_theme; + self.syntax_color_level = theme.color_level; + self.applied_diff_highlights.clear(); + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn edit_highlight_needs_tick(&self) -> bool { + self.edit_highlight_runtime + .as_ref() + .is_some_and(EditHighlightRuntime::has_pending) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn poll_edit_highlight_results(&mut self) -> bool { + let Some(runtime) = self.edit_highlight_runtime.as_mut() else { + return false; + }; + #[cfg(test)] + let drained = match self.edit_highlight_drain { + Some(drain) => drain(runtime), + None => runtime.drain_results(), + }; + #[cfg(not(test))] + let drained = runtime.drain_results(); + + let mut redraw = false; + for result in drained.results { + redraw |= self.apply_edit_highlight_result(result); + } + if drained.disconnected { + self.edit_highlight_runtime = None; + } + redraw + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn refined_diff_styles( + &self, + message_index: usize, + tool_id: &str, + ) -> Option<&crate::diff_highlight::RefinedDiffStyles> { + let message = self.messages.get(message_index)?; + let ChatMessage::ToolCall { id, .. } = message else { + return None; + }; + (id == tool_id) + .then(|| { + Self::refined_diff_styles_for_message( + &self.message_revisions, + &self.applied_diff_highlights, + message_index, + message, + ) + }) + .flatten() + } + + pub(crate) fn refined_diff_styles_for_message<'a>( + revisions: &[u64], + highlights: &'a HashMap, + message_index: usize, + message: &ChatMessage, + ) -> Option<&'a crate::diff_highlight::RefinedDiffStyles> { + let revision = *revisions.get(message_index)?; + let ChatMessage::ToolCall { id, .. } = message else { + return None; + }; + let highlight = highlights.get(&revision)?; + (highlight.tool_id == *id).then_some(highlight.styles.as_ref()) + } + + fn new_edit_highlight_runtime(&self) -> std::io::Result { + #[cfg(test)] + { + (self.edit_highlight_runtime_factory)() + } + #[cfg(not(test))] + { + EditHighlightRuntime::new() + } + } + + fn resolve_edit_target(&self, target: &str) -> Option<(PathBuf, String)> { + let target_path = Path::new(target); + if target_path.as_os_str().is_empty() || target_path.is_absolute() { + return None; + } + let configured_workspace = self.workspace_root.as_ref()?; + let resolved_path = + orca_tools::resolve_workspace_path(configured_workspace, Some(target)).ok()?; + let display_path = resolved_path + .strip_prefix(configured_workspace) + .ok()? + .to_string_lossy() + .replace('\\', "/"); + if display_path.is_empty() { + return None; + } + let workspace_root = configured_workspace.canonicalize().ok()?; + if !workspace_root.is_dir() { + return None; + } + let absolute_path = resolved_path.canonicalize().ok()?; + if !absolute_path.starts_with(&workspace_root) || !absolute_path.is_file() { + return None; + } + Some((absolute_path, display_path)) + } + + #[cfg_attr(not(test), allow(dead_code))] + fn edit_target_matches_job(&self, target: &str, job: &EditHighlightJob) -> bool { + self.resolve_edit_target(target) + .is_some_and(|(absolute_path, display_path)| { + absolute_path == job.absolute_path && display_path == job.display_path + }) + } + + fn submit_edit_highlight_for_message(&mut self, message_index: usize) { + self.reconcile_message_tracking(); + let Some(ChatMessage::ToolCall { + id, + target: Some(target), + status, + diff: Some(diff), + .. + }) = self.messages.get(message_index) + else { + return; + }; + if status != "completed" || diff.trim().is_empty() { + return; + } + + let parsed = crate::diff_highlight::parse_unified_diff(diff); + let Some(destination_path) = parsed.destination_path.as_deref() else { + return; + }; + let Some((absolute_path, display_path)) = self.resolve_edit_target(target) else { + return; + }; + let Some(normalized_destination) = normalize_diff_relative_path(destination_path) else { + return; + }; + if !parsed_diff_structure_matches_target(&parsed, diff, Path::new(&display_path)) + || normalized_destination != Path::new(&display_path) + || !parsed_diff_has_valid_new_side(&parsed) + || highlighter_for_path( + &normalized_destination, + self.syntax_theme, + self.syntax_color_level, + ) + .is_none() + { + return; + } + + let Some(message_revision) = self.message_revisions.get(message_index).copied() else { + return; + }; + let tool_id = id.clone(); + + if self.edit_highlight_runtime.is_none() { + let Ok(runtime) = self.new_edit_highlight_runtime() else { + return; + }; + self.edit_highlight_runtime = Some(runtime); + } + let runtime = self + .edit_highlight_runtime + .as_mut() + .expect("edit highlight runtime initialized"); + let job = EditHighlightJob { + job_id: runtime.allocate_job_id(), + tool_id, + message_index, + message_revision, + syntax_theme_revision: syntax_style_revision( + self.syntax_theme, + self.syntax_color_level, + ), + syntax_theme: self.syntax_theme, + syntax_color_level: self.syntax_color_level, + absolute_path, + display_path, + parsed, + }; + if !runtime.submit(job) { + self.edit_highlight_runtime = None; + } + } + + #[cfg_attr(not(test), allow(dead_code))] + fn apply_edit_highlight_result(&mut self, result: EditHighlightResult) -> bool { + let Some(runtime) = self.edit_highlight_runtime.as_mut() else { + return false; + }; + if !runtime.pending_matches(&result.job) || !runtime.finish_pending(&result.job) { + return false; + } + let EditHighlightOutcome::Ready { styles } = result.outcome else { + return false; + }; + + let job = result.job; + if self.syntax_theme != job.syntax_theme + || self.syntax_color_level != job.syntax_color_level + || syntax_style_revision(self.syntax_theme, self.syntax_color_level) + != job.syntax_theme_revision + || self.message_revisions.get(job.message_index).copied() != Some(job.message_revision) + { + return false; + } + let Some(ChatMessage::ToolCall { + id, + target: Some(target), + status, + diff: Some(diff), + .. + }) = self.messages.get(job.message_index) + else { + return false; + }; + if id != &job.tool_id || status != "completed" { + return false; + } + let current_parsed = crate::diff_highlight::parse_unified_diff(diff); + let Some(current_destination) = current_parsed.destination_path.as_deref() else { + return false; + }; + let Some(normalized_destination) = normalize_diff_relative_path(current_destination) else { + return false; + }; + if current_parsed != job.parsed + || normalized_destination.to_string_lossy().replace('\\', "/") != job.display_path + || !self.edit_target_matches_job(target, &job) + { + return false; + } + + if !self.touch_message(job.message_index) { + return false; + } + let Some(applied_revision) = self.message_revisions.get(job.message_index).copied() else { + return false; + }; + self.applied_diff_highlights.insert( + applied_revision, + AppliedDiffHighlight { + tool_id: job.tool_id, + display_path: job.display_path, + styles, + }, + ); + true + } + + fn clear_pending_edit_highlights(&mut self) { + if let Some(runtime) = self.edit_highlight_runtime.as_mut() { + runtime.clear_pending(); + } + } + + fn remove_applied_highlight_for_message(&mut self, index: usize) { + if matches!(self.messages.get(index), Some(ChatMessage::ToolCall { .. })) + && let Some(revision) = self.message_revisions.get(index) + { + self.applied_diff_highlights.remove(revision); + } + } + + fn remove_applied_highlights_for_tool_id(&mut self, tool_id: &str) { + self.applied_diff_highlights + .retain(|_, highlight| highlight.tool_id != tool_id); + } + + fn prune_applied_diff_highlights(&mut self) { + let present_revisions = self + .messages + .iter() + .zip(&self.message_revisions) + .filter_map(|(message, revision)| match message { + ChatMessage::ToolCall { id, .. } => Some((*revision, id.as_str())), + _ => None, + }) + .collect::>(); + self.applied_diff_highlights.retain(|revision, highlight| { + present_revisions + .get(revision) + .is_some_and(|tool_id| *tool_id == highlight.tool_id) + }); + } + + #[cfg(test)] + fn pending_edit_highlight_count(&self) -> usize { + self.edit_highlight_runtime + .as_ref() + .map_or(0, EditHighlightRuntime::pending_count) + } + + #[cfg(test)] + fn successful_edit_highlight_submit_count(&self) -> usize { + self.edit_highlight_runtime + .as_ref() + .map_or(0, EditHighlightRuntime::successful_submit_count) + } + + #[cfg(test)] + pub(crate) fn pending_edit_highlight_job(&self, tool_id: &str) -> Option { + self.edit_highlight_runtime.as_ref()?.pending_job(tool_id) + } + + #[cfg(test)] + fn edit_highlight_runtime_started(&self) -> bool { + self.edit_highlight_runtime.is_some() + } + + #[cfg(test)] + pub(crate) fn syntax_workspace_root_for_test(&self) -> Option<&Path> { + self.workspace_root.as_deref() + } + + #[cfg(test)] + pub(crate) fn syntax_theme_for_test(&self) -> SyntaxTheme { + self.syntax_theme + } + + #[cfg(test)] + pub(crate) fn syntax_color_level_for_test(&self) -> TerminalColorLevel { + self.syntax_color_level + } + + #[cfg(test)] + pub(crate) fn edit_highlight_runtime_started_for_test(&self) -> bool { + self.edit_highlight_runtime.is_some() + } + + #[cfg(test)] + pub(crate) fn pending_edit_highlight_count_for_test(&self) -> usize { + self.pending_edit_highlight_count() + } + + #[cfg(test)] + fn set_edit_highlight_runtime_factory_for_test( + &mut self, + factory: EditHighlightRuntimeFactory, + ) { + self.edit_highlight_runtime_factory = factory; + } + + #[cfg(test)] + pub(crate) fn set_edit_highlight_drain_for_test(&mut self, drain: Option) { + self.edit_highlight_drain = drain; + } + /// Map a mouse position to transcript content space; `None` outside the /// transcript area (or before the first frame rendered it). pub(crate) fn transcript_pos_at( @@ -933,6 +1522,7 @@ impl AppState { }) == Some(¬ice); if !duplicate { + self.finish_assistant_stream(); self.push_message(ChatMessage::System(notice)); } } @@ -964,6 +1554,15 @@ impl AppState { pub(crate) fn push_message(&mut self, message: ChatMessage) { self.reconcile_message_tracking(); + if let ChatMessage::ToolCall { id, .. } = &message { + let reused_tool_id = self.messages.iter().any( + |existing| matches!(existing, ChatMessage::ToolCall { id: existing_id, .. } if existing_id == id), + ); + self.remove_applied_highlights_for_tool_id(id); + if reused_tool_id { + self.clear_pending_edit_highlights(); + } + } let revision = self.allocate_message_revision(); self.messages.push(message); self.message_revisions.push(revision); @@ -977,7 +1576,11 @@ impl AppState { } pub(crate) fn replace_messages(&mut self, messages: impl IntoIterator) { + self.reset_assistant_stream(); + self.reset_queued_user_messages(); self.messages = messages.into_iter().collect(); + self.applied_diff_highlights.clear(); + self.clear_pending_edit_highlights(); self.reset_message_tracking(); self.finalized_count = 0; self.flushed_count = 0; @@ -986,9 +1589,14 @@ impl AppState { } pub(crate) fn clear_messages(&mut self) { + self.reset_assistant_stream(); + self.reset_queued_user_messages(); + self.transcript_search.reset(); self.messages.clear(); self.message_revisions.clear(); self.transcript_render_cache.clear(); + self.applied_diff_highlights.clear(); + self.clear_pending_edit_highlights(); self.finalized_count = 0; self.flushed_count = 0; self.unseen_messages = 0; @@ -996,15 +1604,23 @@ impl AppState { } pub(crate) fn truncate_messages(&mut self, len: usize) { + if self + .assistant_stream_tail + .is_none_or(|tail_index| tail_index >= len) + { + self.reset_assistant_stream(); + } self.reconcile_message_tracking(); if len < self.messages.len() { self.invalidate_selection(); + self.clear_pending_edit_highlights(); } self.messages.truncate(len); self.message_revisions.truncate(len); self.transcript_render_cache.truncate(len); self.finalized_count = self.finalized_count.min(len); self.flushed_count = self.flushed_count.min(len); + self.prune_applied_diff_highlights(); } pub(crate) fn replace_message(&mut self, index: usize, message: ChatMessage) -> bool { @@ -1012,6 +1628,7 @@ impl AppState { if index >= self.messages.len() { return false; } + self.remove_applied_highlight_for_message(index); self.messages[index] = message; self.touch_message(index); true @@ -1023,6 +1640,7 @@ impl AppState { mutate: impl FnOnce(&mut ChatMessage) -> R, ) -> Option { self.reconcile_message_tracking(); + self.remove_applied_highlight_for_message(index); let result = mutate(self.messages.get_mut(index)?); self.touch_message(index); Some(result) @@ -1033,6 +1651,11 @@ impl AppState { if index >= self.message_revisions.len() { return false; } + let old_revision = self.message_revisions[index]; + if let Some(runtime) = self.edit_highlight_runtime.as_mut() { + runtime.cancel_pending_for_message(index, old_revision); + } + self.remove_applied_highlight_for_message(index); // Rewriting any message but the tail can change its height and shift // every row below it. Tail rewrites (streaming deltas) leave earlier // rows in place, so a selection above the stream stays valid. @@ -1049,25 +1672,45 @@ impl AppState { self.reconcile_message_tracking(); let messages = std::mem::take(&mut self.messages); let revisions = std::mem::take(&mut self.message_revisions); + let active_tail = self.assistant_stream_tail; + let mut retained_tail = None; let finalized_count = self.finalized_count.min(messages.len()); let flushed_count = self.flushed_count.min(messages.len()); let mut retained_finalized = 0; let mut retained_flushed = 0; let mut retained_mask = Vec::with_capacity(messages.len()); + let mut removed_tool_revisions = Vec::new(); for (index, (message, revision)) in messages.into_iter().zip(revisions).enumerate() { let retain = keep(&message); retained_mask.push(retain); if retain { + if active_tail == Some(index) { + retained_tail = Some(self.messages.len()); + } retained_finalized += usize::from(index < finalized_count); retained_flushed += usize::from(index < flushed_count); self.messages.push(message); self.message_revisions.push(revision); + } else if matches!(message, ChatMessage::ToolCall { .. }) { + removed_tool_revisions.push(revision); } } self.transcript_render_cache.retain(&retained_mask); + if active_tail.is_some() { + if retained_tail.is_some() { + self.assistant_stream_tail = retained_tail; + } else { + self.reset_assistant_stream(); + } + } self.finalized_count = retained_finalized; self.flushed_count = retained_flushed; if retained_mask.iter().any(|retain| !retain) { + for revision in removed_tool_revisions { + self.applied_diff_highlights.remove(&revision); + } + self.clear_pending_edit_highlights(); + self.prune_applied_diff_highlights(); self.invalidate_selection(); } } @@ -1130,6 +1773,64 @@ impl AppState { self.auto_scroll = false; } + pub(crate) fn open_transcript_search(&mut self) { + if self.panel_mode == PanelMode::Conversation + && matches!( + self.status, + AppStatus::Idle | AppStatus::Running | AppStatus::WaitingUserInput + ) + { + self.transcript_search.open_new(); + } + } + + pub(crate) fn close_transcript_search(&mut self) { + self.transcript_search.close(); + } + + pub(crate) fn replace_transcript_search_query(&mut self, query: &str) { + self.transcript_search.replace_query(query); + } + + pub(crate) fn refresh_transcript_search(&mut self) { + let generation = self.transcript_render_cache.content_generation(); + let viewport_base = self.viewport_base_row; + let live_start = self.flushed_count.min(self.messages.len()); + let cache = &self.transcript_render_cache; + self.transcript_search + .refresh_with(generation, viewport_base, |query| { + cache.search(live_start, query) + }); + } + + pub(crate) fn search_next(&mut self) { + self.refresh_transcript_search(); + let Some(found) = self.transcript_search.next().cloned() else { + return; + }; + self.scroll_offset = self.transcript_render_cache.reveal_offset( + self.flushed_count, + self.scroll_offset, + self.visible_height, + &found, + ); + self.auto_scroll = false; + } + + pub(crate) fn search_previous(&mut self) { + self.refresh_transcript_search(); + let Some(found) = self.transcript_search.previous().cloned() else { + return; + }; + self.scroll_offset = self.transcript_render_cache.reveal_offset( + self.flushed_count, + self.scroll_offset, + self.visible_height, + &found, + ); + self.auto_scroll = false; + } + pub fn accepts_mouse_scroll_at(&self, now: Instant) -> bool { const COMPLETION_MOUSE_SCROLL_GRACE: std::time::Duration = std::time::Duration::from_millis(800); @@ -1328,10 +2029,104 @@ fn append_input_history(prompt: &str) { } impl AppState { - pub fn record_prompt(&mut self, prompt: String) { - if self - .input_history - .last() + pub(crate) fn enqueue_user_message( + &mut self, + mut message: QueuedUserMessage, + ) -> Result<(), QueuedUserMessage> { + if self.queued_user_messages.len() >= crate::channels::USER_ACTION_CAPACITY { + return Err(message); + } + message.assign_id(self.next_queued_submission_id); + self.next_queued_submission_id = self.next_queued_submission_id.wrapping_add(1).max(1); + self.queued_user_messages.push_back(message); + self.queued_input_error = None; + Ok(()) + } + + pub(crate) fn pop_latest_queued_message(&mut self) -> Option { + let message = self.queued_user_messages.pop_back(); + if message.is_some() { + self.queued_input_error = None; + } + message + } + + pub(crate) fn begin_next_queued_message(&mut self) -> Option { + if self.status != AppStatus::Idle + || !self.queued_follow_up_autosend + || self.queued_submission_in_flight.is_some() + { + return None; + } + let message = self.queued_user_messages.pop_front()?; + self.push_message(ChatMessage::User(message.visible_text().to_string())); + self.enter_running(); + self.scroll_to_bottom(); + self.queued_submission_in_flight = Some(message.clone()); + self.queued_input_error = None; + Some(UserAction::SubmitQueued { + id: message.id(), + prompt: message.submission_text().to_string(), + bindings: message.submission_bindings().clone(), + }) + } + + pub(crate) fn commit_queued_submission_admission(&mut self) { + let Some(prompt) = self + .queued_submission_in_flight + .as_ref() + .map(|message| message.submission_text().to_string()) + else { + return; + }; + self.record_prompt(prompt); + } + + pub(crate) fn rollback_queued_submission(&mut self) -> Option { + let message = self.queued_submission_in_flight.take()?; + self.remove_after_last_user(); + self.queued_user_messages.push_front(message.clone()); + self.set_status(AppStatus::Idle); + Some(message) + } + + pub(crate) fn take_rejected_queued_composer_state(&mut self) -> Option { + let message = self.queued_submission_in_flight.take()?; + self.suspend_queued_follow_up_autosend(); + self.queued_input_error = None; + Some(message.into_composer_state()) + } + + pub(crate) fn suspend_queued_follow_up_autosend(&mut self) { + self.queued_follow_up_autosend = false; + } + + pub(crate) fn resume_queued_follow_up_autosend(&mut self) { + self.queued_follow_up_autosend = true; + } + + pub(crate) fn queued_follow_up_pending_or_in_flight(&self) -> bool { + !self.queued_user_messages.is_empty() || self.queued_submission_in_flight.is_some() + } + + pub(crate) fn queued_submission_matches_id(&self, id: u64) -> bool { + self.queued_submission_in_flight + .as_ref() + .is_some_and(|message| message.id() == id) + } + + pub(crate) fn reset_queued_user_messages(&mut self) { + self.queued_user_messages.clear(); + self.queued_submission_in_flight = None; + self.queued_follow_up_autosend = true; + self.queued_input_error = None; + self.next_queued_submission_id = 1; + } + + pub fn record_prompt(&mut self, prompt: String) { + if self + .input_history + .last() .map(|last| last != &prompt) .unwrap_or(true) { @@ -1475,10 +2270,17 @@ impl AppState { self.suppress_background_main_session_output = false; self.enter_running(); } + TuiEvent::QueuedSubmissionStarted { id } => { + if self.queued_submission_matches_id(id) { + self.queued_submission_in_flight = None; + } + self.enter_running(); + } TuiEvent::ReasoningDelta(text) => { if self.suppress_background_main_session_output { return; } + self.finish_assistant_stream(); let last = self.messages.len().saturating_sub(1); if matches!(self.messages.last(), Some(ChatMessage::Reasoning(_))) { self.mutate_message(last, |message| { @@ -1523,6 +2325,7 @@ impl AppState { }); return; } + self.finish_assistant_stream(); self.push_message(ChatMessage::ToolCall { id, name, @@ -1572,6 +2375,7 @@ impl AppState { *output = progress_output; }); } else { + self.finish_assistant_stream(); self.push_message(ChatMessage::ToolCall { id, name: name.unwrap_or_else(|| "tool".to_string()), @@ -1622,10 +2426,17 @@ impl AppState { if name == "subagent" { return; } - let updated = if let Some(index) = self.messages.iter().rposition(|message| { - matches!(message, ChatMessage::ToolCall { id: existing_id, .. } if existing_id == &id) - }) - { + let message_index = if let Some(index) = self.messages.iter().rposition(|message| { + matches!( + message, + ChatMessage::ToolCall { + id: existing_id, + status, + .. + } if existing_id == &id + && matches!(status.as_str(), "running" | "receiving") + ) + }) { self.mutate_message(index, |message| { let ChatMessage::ToolCall { id: existing_id, @@ -1650,25 +2461,28 @@ impl AppState { *existing_diff = diff.clone(); *existing_kind = kind.clone(); }); - true + index } else { - false - }; - if !updated { + self.finish_assistant_stream(); + let index = self.messages.len(); self.push_message(ChatMessage::ToolCall { - id, - name, + id: id.clone(), + name: name.clone(), target: None, - status, + status: status.clone(), output: if output.is_empty() { None } else { - Some(output) + Some(output.clone()) }, - diff, - kind, + diff: diff.clone(), + kind: kind.clone(), expanded: false, }); + index + }; + if status == "completed" { + self.submit_edit_highlight_for_message(message_index); } } TuiEvent::PlanUpdated { explanation, plan } => { @@ -1689,6 +2503,7 @@ impl AppState { if self.suppress_background_main_session_output { return; } + self.finish_assistant_stream(); self.push_message(ChatMessage::Subagent { id, description, @@ -1732,6 +2547,7 @@ impl AppState { *existing_error = error; }); } else { + self.finish_assistant_stream(); self.push_message(ChatMessage::Subagent { id, description, @@ -1799,6 +2615,7 @@ impl AppState { if self .push_pending_workflow_notification(PendingWorkflowNotification { id, prompt }) { + self.finish_assistant_stream(); self.push_message(ChatMessage::System(format!("Workflow {status}. {summary}"))); } } @@ -1808,6 +2625,7 @@ impl AppState { target, preview, } => { + self.close_transcript_search(); self.set_status(AppStatus::WaitingApproval); let options = ApprovalDialog::options_for(&tool, target.as_deref()); self.approval_dialog = Some(ApprovalDialog { @@ -1829,6 +2647,7 @@ impl AppState { preview, permission_kind, } => { + self.close_transcript_search(); self.set_status(AppStatus::WaitingApproval); let options = ApprovalDialog::options_for(&tool, target.as_deref()); self.approval_dialog = Some(ApprovalDialog { @@ -1850,6 +2669,7 @@ impl AppState { } => { self.set_status(AppStatus::WaitingUserInput); self.pending_input = Some(PendingTuiInput::UserInput(key)); + self.finish_assistant_stream(); let mut message = question; if !choices.is_empty() { message.push_str("\nChoices: "); @@ -1867,6 +2687,7 @@ impl AppState { } => { self.set_status(AppStatus::WaitingUserInput); self.pending_input = Some(PendingTuiInput::McpElicitation(key)); + self.finish_assistant_stream(); let mut lines = vec![format!("MCP {server_name} requests input: {message}")]; match mode { RuntimeMcpElicitationMode::Form => { @@ -1884,7 +2705,17 @@ impl AppState { } self.push_message(ChatMessage::System(lines.join("\n"))); } - TuiEvent::SubmissionRejected { message, .. } => { + TuiEvent::SubmissionRejected { + queued_id, + prompt: _, + message, + } => { + if queued_id.is_some() + && !queued_id.is_some_and(|id| self.queued_submission_matches_id(id)) + { + self.push_message(ChatMessage::Error(message)); + return; + } self.remove_after_last_user(); self.mention_bindings.clear(); self.clear_receiving_tool_progress(); @@ -1892,15 +2723,18 @@ impl AppState { self.set_status(AppStatus::Idle); } TuiEvent::OperationRejected(message) => { + self.reset_assistant_stream(); self.clear_receiving_tool_progress(); self.push_message(ChatMessage::Error(message)); self.set_status(AppStatus::Idle); } TuiEvent::Error(msg) => { + self.finish_assistant_stream(); self.clear_receiving_tool_progress(); self.push_message(ChatMessage::Error(msg)); } TuiEvent::Notice(msg) => { + self.finish_assistant_stream(); self.push_message(ChatMessage::System(msg)); } TuiEvent::MentionSearchDirty { .. } | TuiEvent::MentionCatalogDirty { .. } => {} @@ -1928,6 +2762,7 @@ impl AppState { self.pending_input = None; self.clear_receiving_tool_progress(); self.flush_proposed_plan_parser(); + self.finish_assistant_stream(); self.promote_trailing_reasoning(); self.archive_current_plan(); if was_backgrounded { @@ -1948,6 +2783,7 @@ impl AppState { collapsed_messages, status_text, } => { + self.finish_assistant_stream(); self.push_message(ChatMessage::System(format_compaction_notice( &reason, &strategy, @@ -1970,6 +2806,7 @@ impl AppState { } TuiEvent::GoalCleared => { self.current_goal = None; + self.finish_assistant_stream(); self.push_message(ChatMessage::System("Goal cleared.".to_string())); self.set_status(AppStatus::Idle); } @@ -1983,8 +2820,12 @@ impl AppState { let notice = format_goal_notice(&goal); self.push_goal_notice(notice); } - None => self - .push_message(ChatMessage::System("No goal is currently set.".to_string())), + None => { + self.finish_assistant_stream(); + self.push_message(ChatMessage::System( + "No goal is currently set.".to_string(), + )); + } } if !should_keep_running { self.set_status(AppStatus::Idle); @@ -2023,28 +2864,112 @@ impl AppState { fn push_proposed_plan_segment(&mut self, segment: ProposedPlanSegment) { match segment { - ProposedPlanSegment::Agent(text) => self.push_assistant_delta(text), - ProposedPlanSegment::Plan(text) => self.push_proposed_plan_delta(text), + ProposedPlanSegment::Agent(text) => { + let actions = self.assistant_stream.push(&text); + self.apply_streaming_markdown_actions(actions); + } + ProposedPlanSegment::Plan(text) => { + self.finish_assistant_stream(); + self.push_proposed_plan_delta(text); + } } } - fn push_assistant_delta(&mut self, text: String) { - if text.is_empty() { - return; + fn apply_streaming_markdown_actions(&mut self, actions: Vec) { + for action in actions { + match action { + StreamingMarkdownAction::UpdateTail(text) => { + if let Some(index) = self.assistant_stream_tail { + self.mutate_message(index, |message| { + let ChatMessage::Assistant(existing) = message else { + unreachable!(); + }; + *existing = text; + }); + } else { + let index = self.messages.len(); + self.push_message(ChatMessage::Assistant(text)); + self.assistant_stream_tail = Some(index); + } + } + StreamingMarkdownAction::FreezeTail { + text, + trailing_blank, + } => { + if let Some(index) = self.assistant_stream_tail { + self.replace_message( + index, + ChatMessage::AssistantChunk { + text, + trailing_blank, + }, + ); + } else { + self.push_message(ChatMessage::AssistantChunk { + text, + trailing_blank, + }); + } + } + StreamingMarkdownAction::AppendFrozen { + text, + trailing_blank, + } => self.push_message(ChatMessage::AssistantChunk { + text, + trailing_blank, + }), + StreamingMarkdownAction::ClearTail => { + self.assistant_stream_tail = None; + } + StreamingMarkdownAction::FinishTail(suffix) => { + if let Some(index) = self.assistant_stream_tail { + if !suffix.is_empty() { + self.mutate_message(index, |message| { + let ChatMessage::Assistant(existing) = message else { + unreachable!(); + }; + existing.push_str(&suffix); + }); + } + } else if !suffix.is_empty() { + self.push_message(ChatMessage::Assistant(suffix)); + } + self.assistant_stream_tail = None; + } + } } - let last = self.messages.len().saturating_sub(1); - if matches!(self.messages.last(), Some(ChatMessage::Assistant(_))) { - self.mutate_message(last, |message| { - let ChatMessage::Assistant(existing) = message else { + } + + fn finish_assistant_stream(&mut self) { + let actions = self.assistant_stream.finish(); + self.apply_streaming_markdown_actions(actions); + self.assistant_stream = StreamingMarkdownAssembler::default(); + self.assistant_stream_tail = None; + let Some(index) = self.messages.len().checked_sub(1) else { + return; + }; + let needs_separator = matches!( + self.messages.get(index), + Some(ChatMessage::AssistantChunk { + trailing_blank: false, + .. + }) + ); + if needs_separator { + self.mutate_message(index, |message| { + let ChatMessage::AssistantChunk { trailing_blank, .. } = message else { unreachable!(); }; - existing.push_str(&text); + *trailing_blank = true; }); - } else { - self.push_message(ChatMessage::Assistant(text)); } } + fn reset_assistant_stream(&mut self) { + self.assistant_stream = StreamingMarkdownAssembler::default(); + self.assistant_stream_tail = None; + } + fn push_proposed_plan_delta(&mut self, text: String) { if text.is_empty() { return; @@ -2189,7 +3114,8 @@ impl AppState { ChatMessage::Reasoning(_) | ChatMessage::Assistant(_) | ChatMessage::ProposedPlan(_) => turn_ended || !is_last, - ChatMessage::User(_) + ChatMessage::AssistantChunk { .. } + | ChatMessage::User(_) | ChatMessage::Error(_) | ChatMessage::System(_) | ChatMessage::PlanUpdate { .. } => true, @@ -2339,6 +3265,40 @@ mod tests { use orca_core::plan_types::PlanStatus; use orca_core::task_types::{TaskStatus, TaskType}; + fn inserted_source_line<'a>( + lines: &'a [ratatui::text::Line<'static>], + source: &str, + ) -> &'a ratatui::text::Line<'static> { + lines + .iter() + .find(|line| { + line.to_string().contains(source) + && line + .spans + .first() + .is_some_and(|span| span.content.ends_with("+ ")) + }) + .unwrap_or_else(|| panic!("inserted source line containing {source:?}")) + } + + fn normalized_source_spans( + spans: &[ratatui::text::Span<'_>], + ) -> crate::syntax_highlight::StyledSourceLine { + let mut output: crate::syntax_highlight::StyledSourceLine = Vec::new(); + for span in spans { + let mut style = span.style; + style.bg = None; + if let Some(previous) = output.last_mut() + && previous.style == style + { + previous.content.to_mut().push_str(span.content.as_ref()); + continue; + } + output.push(ratatui::text::Span::styled(span.content.to_string(), style)); + } + output + } + fn state() -> AppState { let (tx, _rx) = mpsc::unbounded(); AppState::new( @@ -2349,149 +3309,612 @@ mod tests { ) } - fn interaction_key(kind: TuiInteractionKind, id: &str) -> TuiInteractionKey { - TuiInteractionKey::new( - orca_core::cancel::OperationIdAllocator::new().allocate(), - id, - kind, - ) - } - - fn dummy_selection() -> crate::selection::TranscriptSelection { - let pos = crate::selection::SelectionPos { row: 0, col: 0 }; - let end = crate::selection::SelectionPos { row: 1, col: 3 }; - crate::selection::TranscriptSelection { - anchor: pos, - head: end, - dragging: false, - granularity: crate::selection::SelectionGranularity::Cell, - origin: (pos, end), - } - } - #[test] - fn transcript_mutations_invalidate_the_selection_only_when_rows_can_shift() { + fn app_state_onboarding_defaults_and_initializes_from_effective_config() { let mut state = state(); - state.push_message(ChatMessage::System("one".to_string())); - state.push_message(ChatMessage::System("two".to_string())); - state.push_message(ChatMessage::System("three".to_string())); + assert_eq!( + state.onboarding.step(), + crate::onboarding::OnboardingStep::Welcome, + ); + assert_eq!( + state.onboarding.draft_provider(), + orca_core::config::ProviderKind::DeepSeek, + ); + assert_eq!(state.onboarding.draft_model(), orca_core::model::AUTO_MODEL,); + assert_eq!( + state.onboarding.selected_theme(), + orca_core::config::ThemeName::Auto, + ); - // Appending and rewriting the TAIL keep the selection: earlier rows - // cannot move. - state.selection = Some(dummy_selection()); - state.push_message(ChatMessage::System("four".to_string())); - assert!(state.selection.is_some()); - state.touch_message(state.messages.len() - 1); - assert!(state.selection.is_some()); + let mut config = crate::test_support::test_run_config(); + config.provider = orca_core::config::ProviderKind::DeepSeekFixture; + config.model = orca_core::model::ModelSelection::from_unchecked(Some( + orca_core::model::FLASH_MODEL.to_string(), + )); + config.theme = orca_core::config::ThemeName::Catppuccin; - // Rewriting a non-tail message can change its height: cleared. - state.touch_message(1); - assert_eq!(state.selection, None); + state.initialize_onboarding(&config); - // Removing messages shifts rows: cleared. - state.selection = Some(dummy_selection()); - state.truncate_messages(3); - assert_eq!(state.selection, None); + assert_eq!( + state.onboarding.draft_provider(), + orca_core::config::ProviderKind::DeepSeek, + ); + assert_eq!( + state.onboarding.draft_model(), + orca_core::model::FLASH_MODEL, + ); + assert_eq!( + state.onboarding.selected_theme(), + orca_core::config::ThemeName::Catppuccin, + ); + assert_eq!( + config.provider, + orca_core::config::ProviderKind::DeepSeekFixture, + ); + assert_eq!(config.model.display_name(), orca_core::model::FLASH_MODEL); + assert_eq!(config.theme, orca_core::config::ThemeName::Catppuccin); + } - state.selection = Some(dummy_selection()); - state.retain_messages( - |message| !matches!(message, ChatMessage::System(text) if text == "two"), + #[test] + fn theme_projection_updates_syntax_revision_and_clears_only_applied_highlights() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + let revision = state.message_revisions[job.message_index]; + state.applied_diff_highlights.insert( + revision, + AppliedDiffHighlight { + tool_id: job.tool_id.clone(), + display_path: job.display_path.clone(), + styles: Arc::new(Default::default()), + }, + ); + let pending_before = state.pending_edit_highlight_count_for_test(); + let runtime_started_before = state.edit_highlight_runtime_started_for_test(); + let theme = crate::theme::Theme::resolve( + ThemeName::Light, + crate::terminal_capabilities::TerminalProfile { + background: crate::terminal_capabilities::TerminalBackground::Dark, + color_level: TerminalColorLevel::Ansi256, + }, ); - assert_eq!(state.selection, None); - // A retain that keeps everything moves nothing: selection survives. - state.selection = Some(dummy_selection()); - state.retain_messages(|_| true); - assert!(state.selection.is_some()); + state.apply_theme_projection(&theme); - state.selection = Some(dummy_selection()); - state.clear_messages(); - assert_eq!(state.selection, None); + assert_eq!(state.syntax_theme_for_test(), theme.syntax_theme); + assert_eq!(state.syntax_color_level_for_test(), theme.color_level); + assert_eq!( + syntax_style_revision( + state.syntax_theme_for_test(), + state.syntax_color_level_for_test(), + ), + theme.syntax_theme_revision, + ); + assert!(state.applied_diff_highlights.is_empty()); + assert_eq!( + state.pending_edit_highlight_count_for_test(), + pending_before, + ); + assert_eq!( + state.edit_highlight_runtime_started_for_test(), + runtime_started_before, + ); } - fn session(id: &str, title: &str) -> SessionSummary { - use chrono::Utc; - use std::path::PathBuf; - SessionSummary { - session_id: id.to_string(), - title: title.to_string(), - cwd: "/tmp".to_string(), - provider: "deepseek".to_string(), - model: None, - created_at: Utc::now(), - updated_at: Utc::now(), - path: PathBuf::from("/tmp"), - archived: false, - parent_id: None, - forked: false, - approval_mode: None, - active_permission_profile: None, - runtime_workspace_roots: Vec::new(), - permission_rule_count: 0, - additional_working_directories: Vec::new(), - network_domain_permissions: Default::default(), - } + fn prepare_transcript_cache(state: &mut AppState, width: usize) { + let theme = crate::theme::Theme::named(orca_core::config::ThemeName::Dark); + let messages = &state.messages; + let revisions = &state.message_revisions; + state.transcript_render_cache.prepare( + messages, + revisions, + TranscriptRenderContext::new(&theme, width, 0, false), + |_, message, theme, width, tick, force_expand| { + crate::ui::build_lines_for_messages( + std::slice::from_ref(message), + theme, + width, + tick, + force_expand, + ) + }, + ); } - fn workflow_task_summary(id: &str, name: &str) -> BackgroundTaskSummary { - BackgroundTaskSummary { - id: id.to_string(), - task_type: TaskType::Workflow, - status: TaskStatus::Running, - is_backgrounded: false, - description: name.to_string(), - created_at_ms: 1_000, - started_at_ms: Some(1_000), - completed_at_ms: None, - command: None, - agent_type: None, - server: None, - tool: None, - pending_tool_call: None, - name: Some(name.to_string()), - workflow_run_id: Some(format!("run-{id}")), - phase_count: Some(1), - workflow_progress: None, - workflow_phases: Vec::new(), - workflow_agents: Vec::new(), - workflow_script_path: None, - workflow_launch_input: None, - workflow_final_summary: None, - workflow_failure_count: 0, - usage: None, - subagent_current_activity: None, - subagent_turn: None, - last_activity_at_ms: None, - result: None, - error: None, - } + fn queued(text: &str) -> crate::queued_input::QueuedUserMessage { + crate::queued_input::QueuedUserMessage::from_composer( + text.to_string(), + Vec::new(), + orca_runtime::mentions::MentionBindings::default(), + ) + .unwrap() } #[test] - fn workflow_notification_action_carries_notification_boundary() { - let source = include_str!("types.rs"); - let user_action = source - .split("pub enum UserAction {") - .nth(1) - .expect("UserAction enum") - .split("pub enum ApprovalOption") - .next() - .expect("UserAction enum body"); + fn queued_follow_ups_promote_fifo_restore_lifo_and_fence_admission() { + let mut state = state(); + state.enqueue_user_message(queued("first")).unwrap(); + state.enqueue_user_message(queued("second")).unwrap(); + state.enqueue_user_message(queued("third")).unwrap(); + state.set_status(AppStatus::Idle); - assert!( - user_action.contains("SubmitWorkflowNotification(PendingWorkflowNotification)"), - "workflow notification actions should carry the typed notification boundary" + let action = state.begin_next_queued_message().expect("first action"); + assert!(matches!( + action, + UserAction::SubmitQueued { prompt, .. } if prompt == "first" + )); + assert_eq!(state.queued_user_messages.len(), 2); + assert!(state.queued_submission_in_flight.is_some()); + assert!(state.begin_next_queued_message().is_none()); + assert!(matches!( + state.messages.last(), + Some(ChatMessage::User(text)) if text == "first" + )); + assert!(!state.input_history.iter().any(|entry| entry == "first")); + state.commit_queued_submission_admission(); + assert_eq!( + state.input_history.last().map(String::as_str), + Some("first") ); - assert!( - !user_action.contains("SubmitWorkflowNotification { id: String, prompt: String }"), - "workflow notification actions should not split notification id and prompt" + + state.update(TuiEvent::TurnStarted { + turn: 1, + task: None, + }); + assert!(state.queued_submission_in_flight.is_some()); + let id = state.queued_submission_in_flight.as_ref().unwrap().id(); + state.update(TuiEvent::QueuedSubmissionStarted { id }); + assert!(state.queued_submission_in_flight.is_none()); + assert_eq!( + state.pop_latest_queued_message().unwrap().visible_text(), + "third" + ); + assert_eq!( + state.queued_user_messages.front().unwrap().visible_text(), + "second" ); } #[test] - fn session_search_filters_by_title_and_keeps_selection_valid() { + fn queued_follow_up_capacity_matches_user_action_mailbox() { let mut state = state(); - state.session_picker_sessions = vec![ + for index in 0..crate::channels::USER_ACTION_CAPACITY { + state + .enqueue_user_message(queued(&format!("queued {index}"))) + .unwrap(); + } + let rejected = state + .enqueue_user_message(queued("overflow")) + .expect_err("65th item rejected"); + assert_eq!(rejected.visible_text(), "overflow"); + assert_eq!( + state.queued_user_messages.len(), + crate::channels::USER_ACTION_CAPACITY + ); + } + + #[test] + fn queued_submission_rollback_and_rejection_preserve_distinct_paths() { + let mut state = state(); + state.enqueue_user_message(queued("first")).unwrap(); + state.set_status(AppStatus::Idle); + state.begin_next_queued_message().unwrap(); + + let rolled_back = state.rollback_queued_submission().unwrap(); + assert_eq!(rolled_back.visible_text(), "first"); + assert_eq!( + state.queued_user_messages.front().unwrap().visible_text(), + "first" + ); + assert!( + !state + .messages + .iter() + .any(|message| matches!(message, ChatMessage::User(text) if text == "first")) + ); + assert!(!state.input_history.iter().any(|entry| entry == "first")); + + state.begin_next_queued_message().unwrap(); + state.update(TuiEvent::SubmissionRejected { + queued_id: Some(state.queued_submission_in_flight.as_ref().unwrap().id()), + prompt: "first".to_string(), + message: "rejected".to_string(), + }); + let restored = state.take_rejected_queued_composer_state().unwrap(); + assert_eq!(restored.visible_text, "first"); + assert!(state.queued_submission_in_flight.is_none()); + assert!(!state.queued_follow_up_autosend); + assert!(state.queued_user_messages.is_empty()); + } + + #[test] + fn unrelated_turn_start_and_rejection_do_not_consume_queued_admission_fence() { + let mut state = state(); + state.enqueue_user_message(queued("queued prompt")).unwrap(); + state.set_status(AppStatus::Idle); + state.begin_next_queued_message().unwrap(); + + state.update(TuiEvent::TurnStarted { + turn: 1, + task: None, + }); + assert!(state.queued_submission_in_flight.is_some()); + + state.update(TuiEvent::SubmissionRejected { + queued_id: Some(u64::MAX), + prompt: "other prompt".to_string(), + message: "other rejection".to_string(), + }); + assert!(state.queued_submission_in_flight.is_some()); + } + + #[test] + fn conversation_replacement_resets_all_queued_follow_up_state() { + for clear in [false, true] { + let mut state = state(); + state.enqueue_user_message(queued("queued")).unwrap(); + state.queued_submission_in_flight = Some(queued("in flight")); + state.suspend_queued_follow_up_autosend(); + state.queued_input_error = Some("full".to_string()); + + if clear { + state.clear_messages(); + } else { + state.replace_messages([ChatMessage::System("replacement".to_string())]); + } + + assert!(state.queued_user_messages.is_empty()); + assert!(state.queued_submission_in_flight.is_none()); + assert!(state.queued_follow_up_autosend); + assert!(state.queued_input_error.is_none()); + assert!(state.queued_user_messages.is_empty()); + assert!(state.queued_submission_in_flight.is_none()); + assert!(!state.queued_follow_up_pending_or_in_flight()); + } + } + + #[test] + fn opening_search_preserves_scroll_and_refresh_selects_viewport_match() { + let mut state = state(); + state.push_message(ChatMessage::Assistant( + "first hit\nsecond\nthird hit".to_string(), + )); + prepare_transcript_cache(&mut state, 20); + state.scroll_offset = 1; + state.viewport_base_row = 1; + state.open_transcript_search(); + state.replace_transcript_search_query("hit"); + state.refresh_transcript_search(); + + assert!(state.transcript_search.open); + assert_eq!(state.scroll_offset, 1); + assert_eq!( + state + .transcript_search + .active_match() + .map(|found| found.start.row), + Some(2) + ); + } + + #[test] + fn explicit_search_jump_disables_follow_and_reveals_match() { + let mut state = state(); + for index in 0..30 { + state.push_message(ChatMessage::System(format!("line {index} target"))); + } + prepare_transcript_cache(&mut state, 80); + state.visible_height = 5; + state.scroll_offset = 20; + state.auto_scroll = true; + state.open_transcript_search(); + state.replace_transcript_search_query("target"); + state.refresh_transcript_search(); + + state.search_next(); + + assert!(!state.auto_scroll); + let active = state.transcript_search.active_match().unwrap(); + assert!(active.start.row >= state.scroll_offset); + assert!(active.start.row < state.scroll_offset + state.visible_height); + } + + #[test] + fn clear_resets_search_but_truncate_reconciles_lazily() { + let mut state = state(); + state.open_transcript_search(); + state.replace_transcript_search_query("x"); + state.push_message(ChatMessage::System("x".to_string())); + prepare_transcript_cache(&mut state, 40); + state.refresh_transcript_search(); + assert_eq!(state.transcript_search.match_count(), 1); + + state.truncate_messages(0); + state.refresh_transcript_search(); + assert_eq!(state.transcript_search.match_count(), 0); + assert_eq!(state.transcript_search.query(), "x"); + + state.clear_messages(); + assert!(!state.transcript_search.open); + assert_eq!(state.transcript_search.query(), ""); + } + + #[test] + fn append_and_retain_preserve_active_revision_identity() { + let mut state = state(); + state.push_message(ChatMessage::System("remove".to_string())); + state.push_message(ChatMessage::System("target".to_string())); + prepare_transcript_cache(&mut state, 40); + state.open_transcript_search(); + state.replace_transcript_search_query("target"); + state.refresh_transcript_search(); + let identity = state + .transcript_search + .active_match() + .unwrap() + .line_identity; + + state.push_message(ChatMessage::System("later target".to_string())); + prepare_transcript_cache(&mut state, 40); + state.refresh_transcript_search(); + assert_eq!( + state + .transcript_search + .active_match() + .unwrap() + .line_identity, + identity + ); + + state.retain_messages( + |message| !matches!(message, ChatMessage::System(text) if text == "remove"), + ); + prepare_transcript_cache(&mut state, 40); + state.refresh_transcript_search(); + assert_eq!( + state + .transcript_search + .active_match() + .unwrap() + .line_identity, + identity + ); + } + + #[test] + fn one_append_rebuilds_one_message_then_rescans_without_render_rebuilds() { + let mut state = state(); + for index in 0..1_000 { + state.push_message(ChatMessage::System(format!("item {index} needle"))); + } + prepare_transcript_cache(&mut state, 80); + state.open_transcript_search(); + state.replace_transcript_search_query("needle"); + state.refresh_transcript_search(); + let scans = state.transcript_search.scan_count_for_test(); + + state.push_message(ChatMessage::System("last needle".to_string())); + prepare_transcript_cache(&mut state, 80); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 1); + let render_generation = state.transcript_render_cache.content_generation(); + state.refresh_transcript_search(); + assert_eq!(state.transcript_search.scan_count_for_test(), scans + 1); + assert_eq!( + state.transcript_render_cache.content_generation(), + render_generation + ); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 1); + } + + #[test] + fn removal_chooses_nearest_following_match_and_open_does_not_disable_follow() { + let mut state = state(); + for text in ["target one", "middle", "target two"] { + state.push_message(ChatMessage::System(text.to_string())); + } + prepare_transcript_cache(&mut state, 40); + state.auto_scroll = true; + state.open_transcript_search(); + assert!(state.auto_scroll); + state.replace_transcript_search_query("target"); + state.refresh_transcript_search(); + let first_revision = state + .transcript_search + .active_match() + .unwrap() + .line_identity; + + state.retain_messages( + |message| !matches!(message, ChatMessage::System(text) if text == "target one"), + ); + prepare_transcript_cache(&mut state, 40); + state.refresh_transcript_search(); + assert_ne!( + state + .transcript_search + .active_match() + .unwrap() + .line_identity, + first_revision + ); + assert_eq!(state.transcript_search.match_count(), 1); + } + + #[test] + fn approval_closes_search_but_preserves_query() { + let mut state = state(); + state.open_transcript_search(); + state.replace_transcript_search_query("target"); + state.update(TuiEvent::ApprovalNeeded { + key: interaction_key(TuiInteractionKind::Approval, "approval"), + tool: "bash".to_string(), + target: None, + preview: None, + }); + + assert!(!state.transcript_search.open); + assert_eq!(state.transcript_search.query(), "target"); + } + + #[test] + fn fresh_app_state_has_default_syntax_highlight_state() { + let state = state(); + + assert!(state.workspace_git.is_none()); + assert!(state.workspace_root.is_none()); + assert_eq!( + state.syntax_theme, + crate::syntax_highlight::SyntaxTheme::OneHalfDark + ); + assert_eq!( + state.syntax_color_level, + crate::terminal_capabilities::TerminalColorLevel::TrueColor + ); + assert!(state.edit_highlight_runtime.is_none()); + assert!(state.applied_diff_highlights.is_empty()); + } + + fn interaction_key(kind: TuiInteractionKind, id: &str) -> TuiInteractionKey { + TuiInteractionKey::new( + orca_core::cancel::OperationIdAllocator::new().allocate(), + id, + kind, + ) + } + + fn dummy_selection() -> crate::selection::TranscriptSelection { + let pos = crate::selection::SelectionPos { row: 0, col: 0 }; + let end = crate::selection::SelectionPos { row: 1, col: 3 }; + crate::selection::TranscriptSelection { + anchor: pos, + head: end, + dragging: false, + granularity: crate::selection::SelectionGranularity::Cell, + origin: (pos, end), + } + } + + #[test] + fn transcript_mutations_invalidate_the_selection_only_when_rows_can_shift() { + let mut state = state(); + state.push_message(ChatMessage::System("one".to_string())); + state.push_message(ChatMessage::System("two".to_string())); + state.push_message(ChatMessage::System("three".to_string())); + + // Appending and rewriting the TAIL keep the selection: earlier rows + // cannot move. + state.selection = Some(dummy_selection()); + state.push_message(ChatMessage::System("four".to_string())); + assert!(state.selection.is_some()); + state.touch_message(state.messages.len() - 1); + assert!(state.selection.is_some()); + + // Rewriting a non-tail message can change its height: cleared. + state.touch_message(1); + assert_eq!(state.selection, None); + + // Removing messages shifts rows: cleared. + state.selection = Some(dummy_selection()); + state.truncate_messages(3); + assert_eq!(state.selection, None); + + state.selection = Some(dummy_selection()); + state.retain_messages( + |message| !matches!(message, ChatMessage::System(text) if text == "two"), + ); + assert_eq!(state.selection, None); + + // A retain that keeps everything moves nothing: selection survives. + state.selection = Some(dummy_selection()); + state.retain_messages(|_| true); + assert!(state.selection.is_some()); + + state.selection = Some(dummy_selection()); + state.clear_messages(); + assert_eq!(state.selection, None); + } + + fn session(id: &str, title: &str) -> SessionSummary { + use chrono::Utc; + use std::path::PathBuf; + SessionSummary { + session_id: id.to_string(), + title: title.to_string(), + cwd: "/tmp".to_string(), + provider: "deepseek".to_string(), + model: None, + created_at: Utc::now(), + updated_at: Utc::now(), + path: PathBuf::from("/tmp"), + archived: false, + parent_id: None, + forked: false, + approval_mode: None, + active_permission_profile: None, + runtime_workspace_roots: Vec::new(), + permission_rule_count: 0, + additional_working_directories: Vec::new(), + network_domain_permissions: Default::default(), + } + } + + fn workflow_task_summary(id: &str, name: &str) -> BackgroundTaskSummary { + BackgroundTaskSummary { + id: id.to_string(), + task_type: TaskType::Workflow, + status: TaskStatus::Running, + is_backgrounded: false, + description: name.to_string(), + created_at_ms: 1_000, + started_at_ms: Some(1_000), + completed_at_ms: None, + command: None, + agent_type: None, + server: None, + tool: None, + pending_tool_call: None, + name: Some(name.to_string()), + workflow_run_id: Some(format!("run-{id}")), + phase_count: Some(1), + workflow_progress: None, + workflow_phases: Vec::new(), + workflow_agents: Vec::new(), + workflow_script_path: None, + workflow_launch_input: None, + workflow_final_summary: None, + workflow_failure_count: 0, + usage: None, + subagent_current_activity: None, + subagent_turn: None, + last_activity_at_ms: None, + result: None, + error: None, + } + } + + #[test] + fn workflow_notification_action_carries_notification_boundary() { + let source = include_str!("types.rs"); + let user_action = source + .split("pub enum UserAction {") + .nth(1) + .expect("UserAction enum") + .split("pub enum ApprovalOption") + .next() + .expect("UserAction enum body"); + + assert!( + user_action.contains("SubmitWorkflowNotification(PendingWorkflowNotification)"), + "workflow notification actions should carry the typed notification boundary" + ); + assert!( + !user_action.contains("SubmitWorkflowNotification { id: String, prompt: String }"), + "workflow notification actions should not split notification id and prompt" + ); + } + + #[test] + fn session_search_filters_by_title_and_keeps_selection_valid() { + let mut state = state(); + state.session_picker_sessions = vec![ session("a", "fix the failing auth test"), session("b", "add JWT auth middleware"), session("c", "refactor parser entrypoint"), @@ -2535,6 +3958,8 @@ mod tests { #[test] fn retaining_messages_rebases_watermarks_and_cache_entries() { + use std::cell::RefCell; + let mut state = state(); state.push_message(ChatMessage::User("keep before".to_string())); state.push_message(ChatMessage::System("remove before".to_string())); @@ -2545,11 +3970,8 @@ mod tests { state.transcript_render_cache.prepare( &state.messages, &state.message_revisions, - 40, - &theme, - 0, - false, - |message, _, _, _, _| vec![ratatui::text::Line::from(format!("{message:?}"))], + TranscriptRenderContext::new(&theme, 40, 0, false), + |_, message, _, _, _, _| vec![ratatui::text::Line::from(format!("{message:?}"))], ); assert_eq!(state.transcript_render_cache.populated_len(), 3); @@ -2563,6 +3985,21 @@ mod tests { assert_eq!(state.flushed_count, 1); assert_eq!(state.transcript_render_cache.len(), 2); assert_eq!(state.transcript_render_cache.populated_len(), 2); + + state.touch_message(1); + let built_indices = RefCell::new(Vec::new()); + state.transcript_render_cache.prepare( + &state.messages, + &state.message_revisions, + TranscriptRenderContext::new(&theme, 40, 0, false), + |index, message, _, _, _, _| { + built_indices.borrow_mut().push(index); + vec![ratatui::text::Line::from(format!("{message:?}"))] + }, + ); + + assert_eq!(*built_indices.borrow(), vec![1]); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 1); } #[test] @@ -3122,6 +4559,178 @@ mod tests { assert!(state.finalized_count > 0); } + #[test] + fn complete_lines_mutate_only_the_active_assistant_tail_revision() { + let mut state = state(); + state.update(TuiEvent::MessageDelta("first line\n".to_string())); + let first_revision = state.message_revisions[0]; + state.update(TuiEvent::MessageDelta("second line\n".to_string())); + assert_eq!(state.messages.len(), 1); + assert_ne!(state.message_revisions[0], first_revision); + + let revisions = state.message_revisions.clone(); + state.update(TuiEvent::MessageDelta("hidden half".to_string())); + assert_eq!(state.message_revisions, revisions); + } + + #[test] + fn blank_boundary_freezes_tail_revision_and_new_block_uses_new_tail() { + let mut state = state(); + state.update(TuiEvent::MessageDelta("first\n\n".to_string())); + assert!(matches!( + &state.messages[..], + [ChatMessage::AssistantChunk { + text, + trailing_blank: true, + }] if text == "first\n\n" + )); + let frozen_revision = state.message_revisions[0]; + + state.update(TuiEvent::MessageDelta("second\n".to_string())); + assert!(matches!( + state.messages.last(), + Some(ChatMessage::Assistant(text)) if text == "second\n" + )); + assert_eq!(state.message_revisions[0], frozen_revision); + } + + fn assistant_projection_text(messages: &[ChatMessage]) -> String { + messages + .iter() + .filter_map(|message| match message { + ChatMessage::Assistant(text) | ChatMessage::AssistantChunk { text, .. } => { + Some(text.as_str()) + } + _ => None, + }) + .collect() + } + + #[test] + fn assistant_stream_completion_flushes_partial_unicode_once() { + let mut state = state(); + for delta in ["中", "文👍🏽e\u{301}", "\n尾", "行"] { + state.update(TuiEvent::MessageDelta(delta.to_string())); + } + assert_eq!( + assistant_projection_text(&state.messages), + "中文👍🏽e\u{301}\n" + ); + + state.update(TuiEvent::SessionCompleted { + status: "success".to_string(), + }); + assert_eq!( + assistant_projection_text(&state.messages), + "中文👍🏽e\u{301}\n尾行" + ); + let revisions = state.message_revisions.clone(); + + state.update(TuiEvent::SessionCompleted { + status: "success".to_string(), + }); + assert_eq!( + assistant_projection_text(&state.messages), + "中文👍🏽e\u{301}\n尾行" + ); + assert_eq!(state.message_revisions, revisions); + } + + #[test] + fn proposed_plan_boundaries_preserve_agent_source_order() { + let mut state = state(); + state.update(TuiEvent::MessageDelta("Intro\n\n# Plan\n- inspect\n\nOutro".to_string(), + )); + state.update(TuiEvent::SessionCompleted { + status: "success".to_string(), + }); + + let plan_index = state + .messages + .iter() + .position(|message| matches!(message, ChatMessage::ProposedPlan(_))) + .expect("proposed plan message"); + assert_eq!( + assistant_projection_text(&state.messages[..plan_index]), + "Intro\n" + ); + assert_eq!( + assistant_projection_text(&state.messages[plan_index + 1..]), + "\nOutro" + ); + assert!(matches!( + &state.messages[plan_index], + ChatMessage::ProposedPlan(text) if text == "# Plan\n- inspect\n" + )); + } + + #[test] + fn tool_boundary_finishes_hidden_assistant_text_before_tool_row() { + let mut state = state(); + state.update(TuiEvent::MessageDelta("hidden tail".to_string())); + state.update(TuiEvent::ToolRequested { + id: "tool-1".to_string(), + name: "grep".to_string(), + target: None, + }); + + assert!(matches!( + &state.messages[..], + [ + ChatMessage::Assistant(text), + ChatMessage::ToolCall { id, .. } + ] if text == "hidden tail" && id == "tool-1" + )); + } + + #[test] + fn transcript_reset_discards_hidden_assistant_text() { + let mut state = state(); + state.update(TuiEvent::MessageDelta("discard me".to_string())); + assert!(state.messages.is_empty()); + + state.clear_messages(); + state.update(TuiEvent::MessageDelta("visible\n".to_string())); + state.update(TuiEvent::SessionCompleted { + status: "success".to_string(), + }); + + assert_eq!(assistant_projection_text(&state.messages), "visible\n"); + } + + #[test] + fn retaining_messages_reindexes_the_active_assistant_tail() { + let mut state = state(); + state.push_message(ChatMessage::System("remove".to_string())); + state.update(TuiEvent::MessageDelta("first\n".to_string())); + state.retain_messages( + |message| !matches!(message, ChatMessage::System(text) if text == "remove"), + ); + + state.update(TuiEvent::MessageDelta("second\n".to_string())); + + assert_eq!(state.messages.len(), 1); + assert!(matches!( + state.messages.last(), + Some(ChatMessage::Assistant(text)) if text == "first\nsecond\n" + )); + } + + #[test] + fn system_notice_finishes_hidden_assistant_text_before_notice() { + let mut state = state(); + state.update(TuiEvent::MessageDelta("hidden tail".to_string())); + state.update(TuiEvent::Notice("notice".to_string())); + + assert!(matches!( + &state.messages[..], + [ChatMessage::Assistant(text), ChatMessage::System(notice)] + if text == "hidden tail" && notice == "notice" + )); + } + #[test] fn session_completion_without_receiving_tools_preserves_populated_render_cache() { let mut state = state(); @@ -3130,11 +4739,8 @@ mod tests { state.transcript_render_cache.prepare( &state.messages, &state.message_revisions, - 40, - &theme, - 0, - false, - |message, _, _, _, _| match message { + TranscriptRenderContext::new(&theme, 40, 0, false), + |_, message, _, _, _, _| match message { ChatMessage::Assistant(text) => vec![ratatui::text::Line::from(text.clone())], _ => unreachable!(), }, @@ -3247,6 +4853,7 @@ mod tests { }); state.update(TuiEvent::SubmissionRejected { + queued_id: None, prompt: "review @gone.txt".to_string(), message: "bound file is no longer available".to_string(), }); @@ -3334,15 +4941,20 @@ mod tests { } #[test] - fn flushable_prefix_holds_back_the_streaming_tail_until_turn_end() { + fn flushable_prefix_excludes_hidden_partial_until_completion_flushes_it() { let mut state = state(); state.messages.push(ChatMessage::User("hi".to_string())); state.update(TuiEvent::MessageDelta("partial".to_string())); - // The trailing assistant block is still growing, so mid-turn only the user - // prompt is flushable. + // The partial source line is still hidden, so only the user prompt exists. assert_eq!(state.flushable_prefix_end(false), 1); - // When the turn ends the tail is settled and the whole prefix can flush. + assert_eq!(state.flushable_prefix_end(true), 1); + + state.update(TuiEvent::SessionCompleted { + status: "success".to_string(), + }); + + // Completion flushes the hidden line as a finalized assistant message. assert_eq!(state.flushable_prefix_end(true), 2); } @@ -4240,12 +5852,12 @@ mod tests { task: None, }); state.update(TuiEvent::MessageDelta( - "visible foreground output".to_string(), + "visible foreground output\n".to_string(), )); assert!(matches!( state.messages.last(), - Some(ChatMessage::Assistant(text)) if text == "visible foreground output" + Some(ChatMessage::Assistant(text)) if text == "visible foreground output\n" )); } @@ -4526,4 +6138,2200 @@ mod tests { assert_eq!(state.status, AppStatus::Running); assert_eq!(state.running_started_at, Some(started_at)); } + + const EDIT_DIFF: &str = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +"; + + fn configured_edit_state() -> (tempfile::TempDir, AppState) { + let directory = tempfile::tempdir().expect("edit workspace"); + std::fs::create_dir_all(directory.path().join("src")).expect("source directory"); + std::fs::write(directory.path().join("src/item.py"), "value = 2\n") + .expect("post-edit file"); + let mut state = state(); + state.configure_syntax_highlighting( + directory.path().to_path_buf(), + crate::syntax_highlight::SyntaxTheme::OneHalfDark, + crate::terminal_capabilities::TerminalColorLevel::TrueColor, + ); + (directory, state) + } + + fn submit_live_edit(state: &mut AppState, id: &str, target: &str, diff: &str) { + state.update(TuiEvent::ToolRequested { + id: id.to_string(), + name: "edit".to_string(), + target: Some(target.to_string()), + }); + state.update(TuiEvent::ToolCompleted { + id: id.to_string(), + name: "edit".to_string(), + status: "completed".to_string(), + output: format!("edited {target}"), + diff: Some(diff.to_string()), + kind: None, + }); + } + + fn malformed_structural_diffs() -> Vec<(&'static str, String)> { + vec![ + ( + "malformed-hunk-candidate", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ malformed coordinates @@ +@@ -1 +1 @@ valid function context +-value = 1 ++value = 2 +" + .to_string(), + ), + ( + "metadata-before-first-hunk", + "\ +--- a/src/item.py ++++ b/src/item.py +arbitrary metadata +@@ -1 +1 @@ +-value = 1 ++value = 2 +" + .to_string(), + ), + ( + "zero-old-start", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -0 +1 @@ +-value = 1 ++value = 2 +" + .to_string(), + ), + ( + "zero-new-start", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +0 @@ +-value = 1 ++value = 2 +" + .to_string(), + ), + ( + "zero-width", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,0 +1,0 @@ +@@ -1 +1 @@ +-value = 1 ++value = 2 +" + .to_string(), + ), + ( + "overflow", + format!( + "--- a/src/item.py\n+++ b/src/item.py\n@@ -{},2 +1,2 @@\n-old = 1\n-old = 2\n+value = 2\n+new = 2\n", + usize::MAX + ), + ), + ( + "duplicate", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +@@ -1 +1 @@ +-value = 1 ++value = 2 +" + .to_string(), + ), + ( + "backward", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -3 +3 @@ +-old = 3 ++new = 3 +@@ -1 +1 @@ +-value = 1 ++value = 2 +" + .to_string(), + ), + ( + "old-overlap", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,2 +1 @@ +-old = 1 +-old = 2 ++new = 1 +@@ -2 +2 @@ +-old = 2 ++value = 2 +" + .to_string(), + ), + ( + "new-overlap", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1,2 @@ +-old = 1 ++new = 1 ++value = 2 +@@ -2 +2 @@ +-old = 2 ++value = 2 +" + .to_string(), + ), + ( + "reused-old-anchor", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,0 +1 @@ ++first = 1 +@@ -1 +3 @@ +-value = 1 ++value = 2 +" + .to_string(), + ), + ( + "reused-new-anchor", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1,0 @@ +-value = 1 +@@ -3 +1 @@ +-other = 3 ++value = 2 +" + .to_string(), + ), + ( + "null-old-range", + "\ +--- /dev/null ++++ b/src/item.py +@@ -1,0 +1 @@ ++value = 2 +" + .to_string(), + ), + ( + "null-new-range", + "\ +--- a/src/item.py ++++ /dev/null +@@ -1 +1,0 @@ +-value = 1 +" + .to_string(), + ), + ( + "both-null", + "\ +--- /dev/null ++++ /dev/null +@@ -0,0 +1 @@ ++value = 2 +" + .to_string(), + ), + ] + } + + fn state_with_submitted_edit_job() -> ( + tempfile::TempDir, + AppState, + crate::edit_highlight_worker::EditHighlightJob, + ) { + let (directory, mut state) = configured_edit_state(); + submit_live_edit(&mut state, "edit-1", "src/item.py", EDIT_DIFF); + let job = state + .pending_edit_highlight_job("edit-1") + .expect("pending edit highlight job"); + (directory, state, job) + } + + fn ready_result( + job: crate::edit_highlight_worker::EditHighlightJob, + ) -> crate::edit_highlight_worker::EditHighlightResult { + use ratatui::style::{Color, Style}; + use ratatui::text::Span; + + let styles = crate::diff_highlight::RefinedDiffStyles::from([( + 1, + vec![Span::styled( + "value = 2".to_string(), + Style::default().fg(Color::Magenta), + )], + )]); + crate::edit_highlight_worker::EditHighlightResult { + job, + outcome: crate::edit_highlight_worker::EditHighlightOutcome::Ready { + styles: Arc::new(styles), + }, + } + } + + #[cfg(unix)] + fn real_alias_edit_state() -> ( + tempfile::TempDir, + AppState, + crate::edit_highlight_worker::EditHighlightJob, + ) { + use std::os::unix::fs::symlink; + + let (directory, mut state) = configured_edit_state(); + let alias = directory.path().join("src/alias.py"); + symlink(directory.path().join("src/item.py"), &alias).expect("initial alias"); + let request = orca_core::tool_types::ToolRequest { + id: "alias-edit".to_string(), + name: orca_core::tool_types::ToolName::Edit, + action: orca_core::approval_types::ActionKind::Write, + target: Some("src/alias.py".to_string()), + raw_arguments: Some( + r#"{"path":"src/alias.py","old_text":"value = 2","new_text":"value = 3"}"# + .to_string(), + ), + }; + let result = orca_tools::edit::execute(&request, directory.path()); + assert_eq!(result.status, orca_core::tool_types::ToolStatus::Completed); + let preview = result + .file_change_preview + .as_deref() + .expect("committed alias preview"); + let orca_core::tool_types::FileChangePreview::UnifiedDiff { text: diff, .. } = preview + else { + panic!("alias edit should produce unified diff"); + }; + state.update(TuiEvent::ToolRequested { + id: request.id.clone(), + name: "edit".to_string(), + target: request.target.clone(), + }); + state.update(TuiEvent::ToolCompleted { + id: request.id, + name: "edit".to_string(), + status: "completed".to_string(), + output: result.output.unwrap_or_default(), + diff: Some(diff.clone()), + kind: None, + }); + let job = state + .pending_edit_highlight_job("alias-edit") + .expect("real alias edit pending job"); + (directory, state, job) + } + + #[cfg(unix)] + #[test] + fn real_edit_producer_keeps_symlink_alias_as_job_display_path() { + let (directory, state, job) = real_alias_edit_state(); + + assert_eq!( + job.absolute_path, + directory + .path() + .join("src/item.py") + .canonicalize() + .expect("canonical item path") + ); + assert_eq!(job.display_path, "src/alias.py"); + assert_eq!(job.parsed.destination_path.as_deref(), Some("src/alias.py")); + assert_eq!(state.pending_edit_highlight_count(), 1); + } + + #[cfg(unix)] + #[test] + fn ready_result_applies_while_symlink_alias_identity_is_unchanged() { + let (_directory, mut state, job) = real_alias_edit_state(); + + assert!(state.apply_edit_highlight_result(ready_result(job.clone()))); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_some() + ); + } + + #[test] + fn successful_live_edit_submits_one_versioned_highlight_job() { + let (directory, mut state) = configured_edit_state(); + + state.update(TuiEvent::ToolRequested { + id: "edit-1".to_string(), + name: "edit".to_string(), + target: Some("src/item.py".to_string()), + }); + state.update(TuiEvent::ToolCompleted { + id: "edit-1".to_string(), + name: "edit".to_string(), + status: "completed".to_string(), + output: "edited src/item.py".to_string(), + diff: Some(EDIT_DIFF.to_string()), + kind: None, + }); + + let job = state + .pending_edit_highlight_job("edit-1") + .expect("pending job"); + assert!(state.edit_highlight_needs_tick()); + assert_eq!(state.pending_edit_highlight_count(), 1); + assert_eq!(state.successful_edit_highlight_submit_count(), 1); + assert_eq!(job.tool_id, "edit-1"); + assert_eq!(job.message_index, 0); + assert_eq!(job.message_revision, state.message_revisions[0]); + assert_eq!( + job.syntax_theme_revision, + crate::terminal_capabilities::syntax_style_revision( + crate::syntax_highlight::SyntaxTheme::OneHalfDark, + crate::terminal_capabilities::TerminalColorLevel::TrueColor, + ) + ); + assert_eq!( + job.syntax_theme, + crate::syntax_highlight::SyntaxTheme::OneHalfDark + ); + assert_eq!( + job.syntax_color_level, + crate::terminal_capabilities::TerminalColorLevel::TrueColor + ); + assert_eq!( + job.absolute_path, + directory + .path() + .join("src/item.py") + .canonicalize() + .expect("canonical target") + ); + assert_eq!(job.display_path, "src/item.py"); + assert_eq!( + job.parsed, + crate::diff_highlight::parse_unified_diff(EDIT_DIFF) + ); + } + + #[test] + fn completion_only_tool_row_has_no_target_and_submits_no_job() { + let (_directory, mut state) = configured_edit_state(); + + state.update(TuiEvent::ToolCompleted { + id: "edit-1".to_string(), + name: "edit".to_string(), + status: "completed".to_string(), + output: "edited src/item.py".to_string(), + diff: Some(EDIT_DIFF.to_string()), + kind: None, + }); + + assert!(matches!( + state.messages.first(), + Some(ChatMessage::ToolCall { target: None, .. }) + )); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn replayed_history_messages_never_submit_jobs() { + let (_directory, mut state) = configured_edit_state(); + let historical = ChatMessage::ToolCall { + id: "historical-edit".to_string(), + name: "edit".to_string(), + target: Some("src/item.py".to_string()), + status: "completed".to_string(), + output: None, + diff: Some(EDIT_DIFF.to_string()), + kind: None, + expanded: false, + }; + + state.push_message(historical.clone()); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + + state.replace_messages([historical]); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn ineligible_live_edits_do_not_start_runtime_or_submit_jobs() { + fn assert_ineligible( + configure: impl FnOnce(&tempfile::TempDir, &mut AppState) -> (String, String, String), + ) { + let (directory, mut state) = configured_edit_state(); + let (status, target, diff) = configure(&directory, &mut state); + state.update(TuiEvent::ToolRequested { + id: "edit-ineligible".to_string(), + name: "edit".to_string(), + target: Some(target), + }); + state.update(TuiEvent::ToolCompleted { + id: "edit-ineligible".to_string(), + name: "edit".to_string(), + status, + output: String::new(), + diff: Some(diff), + kind: None, + }); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + assert_ineligible(|_, _| { + ( + "failed".to_string(), + "src/item.py".to_string(), + EDIT_DIFF.into(), + ) + }); + assert_ineligible(|_, _| { + ( + "cancelled".to_string(), + "src/item.py".to_string(), + EDIT_DIFF.into(), + ) + }); + assert_ineligible(|_, _| { + ( + "completed".to_string(), + "src/item.py".to_string(), + " \n".to_string(), + ) + }); + assert_ineligible(|directory, _| { + std::fs::write(directory.path().join("src/item.unknown"), "value = 2\n") + .expect("unknown syntax file"); + ( + "completed".to_string(), + "src/item.unknown".to_string(), + EDIT_DIFF.replace("item.py", "item.unknown"), + ) + }); + assert_ineligible(|_, _| { + ( + "completed".to_string(), + "src/item.py".to_string(), + format!("{EDIT_DIFF}--- a/src/other.py\n+++ b/src/other.py\n@@ -1 +1 @@\n-a\n+b\n"), + ) + }); + assert_ineligible(|directory, _| { + std::fs::write(directory.path().join("src/item.py"), "").expect("empty post-edit file"); + ( + "completed".to_string(), + "src/item.py".to_string(), + "--- a/src/item.py\n+++ b/src/item.py\n@@ -1 +0,0 @@\n-value = 1\n".to_string(), + ) + }); + assert_ineligible(|directory, _| { + let outside = directory.path().parent().unwrap().join("outside-item.py"); + std::fs::write(&outside, "value = 2\n").expect("outside file"); + ( + "completed".to_string(), + "../outside-item.py".to_string(), + EDIT_DIFF.replace("src/item.py", "../outside-item.py"), + ) + }); + assert_ineligible(|directory, _| { + std::fs::remove_file(directory.path().join("src/item.py")).expect("remove source file"); + std::fs::create_dir(directory.path().join("src/item.py")) + .expect("file-shaped directory"); + ( + "completed".to_string(), + "src/item.py".to_string(), + EDIT_DIFF.into(), + ) + }); + } + + #[test] + fn live_edit_without_configured_workspace_submits_no_job() { + let mut state = state(); + + submit_live_edit(&mut state, "no-workspace", "src/item.py", EDIT_DIFF); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn incomplete_unified_diff_is_rejected_before_runtime_spawn() { + let (_directory, mut state) = configured_edit_state(); + let incomplete = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,2 +1,2 @@ +-value = 1 ++value = 2 +"; + + submit_live_edit(&mut state, "incomplete", "src/item.py", incomplete); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn malformed_structures_fail_closed_across_parser_first_paint_and_app_state() { + let theme = crate::theme::Theme::named(orca_core::config::ThemeName::Dark); + + for (id, diff) in malformed_structural_diffs() { + let parsed = crate::diff_highlight::parse_unified_diff(&diff); + assert!(parsed.has_malformed_hunk, "{id}"); + assert!(!parsed.is_structurally_valid(), "{id}"); + assert_eq!(parsed.raw_fallback.as_deref(), Some(diff.as_str()), "{id}"); + + let rendered = crate::diff_highlight::render_parsed_diff(&parsed, &theme, None); + assert_eq!(rendered.len(), diff.lines().count(), "{id}"); + for (raw_line, rendered_line) in diff.lines().zip(rendered) { + assert_eq!( + rendered_line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(), + format!(" {raw_line}"), + "{id}" + ); + assert_eq!(rendered_line.spans.len(), 1, "{id}: {raw_line:?}"); + } + + let (_directory, mut state) = configured_edit_state(); + submit_live_edit(&mut state, id, "src/item.py", &diff); + assert_eq!(state.pending_edit_highlight_count(), 0, "{id}"); + assert!(!state.edit_highlight_runtime_started(), "{id}"); + } + } + + #[test] + fn headerless_then_headered_diff_is_ambiguous_and_submits_no_job() { + let (_directory, mut state) = configured_edit_state(); + let diff = "\ +@@ -1 +1 @@ +-value = 1 ++value = 2 +--- a/src/item.py ++++ b/src/item.py +@@ -3 +3 @@ +-other = 3 ++other = 4 +"; + + let parsed = crate::diff_highlight::parse_unified_diff(diff); + assert!(parsed.is_structurally_valid()); + assert!(parsed.has_multiple_files); + + submit_live_edit(&mut state, "mixed-sections", "src/item.py", diff); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn extra_source_line_after_completed_hunk_is_rejected_before_runtime_spawn() { + let (_directory, mut state) = configured_edit_state(); + let malformed = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 ++unexpected = 3 +"; + + submit_live_edit(&mut state, "malformed", "src/item.py", malformed); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn missing_file_header_pair_is_rejected_before_runtime_spawn() { + let (_directory, mut state) = configured_edit_state(); + let malformed = "\ ++++ b/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +"; + + submit_live_edit(&mut state, "missing-header", "src/item.py", malformed); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn valid_rename_diff_uses_destination_target_and_submits_job() { + let (_directory, mut state) = configured_edit_state(); + let renamed = "\ +--- a/src/old.py ++++ b/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +"; + + submit_live_edit(&mut state, "rename", "src/item.py", renamed); + + let job = state + .pending_edit_highlight_job("rename") + .expect("rename destination job"); + assert_eq!(state.pending_edit_highlight_count(), 1); + assert_eq!(job.display_path, "src/item.py"); + } + + #[test] + fn valid_added_file_diff_uses_destination_target_and_submits_job() { + let (_directory, mut state) = configured_edit_state(); + let added = "\ +--- /dev/null ++++ b/src/item.py +@@ -0,0 +1 @@ ++value = 2 +"; + + submit_live_edit(&mut state, "add", "src/item.py", added); + + let job = state + .pending_edit_highlight_job("add") + .expect("added file destination job"); + assert_eq!(state.pending_edit_highlight_count(), 1); + assert_eq!(job.display_path, "src/item.py"); + } + + #[test] + fn dev_null_requires_zero_start_and_zero_count() { + let (_directory, mut state) = configured_edit_state(); + let invalid_add = "\ +--- /dev/null ++++ b/src/item.py +@@ -1,0 +1 @@ ++value = 2 +"; + let invalid_delete = "\ +--- a/src/item.py ++++ /dev/null +@@ -1 +1,0 @@ +-value = 1 +"; + + submit_live_edit(&mut state, "invalid-null-add", "src/item.py", invalid_add); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!parsed_diff_structure_matches_target( + &crate::diff_highlight::parse_unified_diff(invalid_add), + invalid_add, + Path::new("src/item.py") + )); + assert!(!parsed_diff_structure_matches_target( + &crate::diff_highlight::parse_unified_diff(invalid_delete), + invalid_delete, + Path::new("src/item.py") + )); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn in_workspace_parent_component_normalizes_and_submits_job() { + let (directory, mut state) = configured_edit_state(); + std::fs::write(directory.path().join("item.py"), "value = 2\n") + .expect("normalized post-edit file"); + let diff = "\ +--- a/item.py ++++ b/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +"; + + submit_live_edit(&mut state, "normalized", "src/../item.py", diff); + + let job = state + .pending_edit_highlight_job("normalized") + .expect("normalized target job"); + assert_eq!(job.display_path, "item.py"); + assert_eq!( + job.absolute_path, + directory + .path() + .join("item.py") + .canonicalize() + .expect("canonical normalized target") + ); + } + + #[cfg(unix)] + #[test] + fn real_edit_producer_lexically_normalizes_symlink_parent_target() { + use std::os::unix::fs::symlink; + + let (directory, mut state) = configured_edit_state(); + let outside = tempfile::tempdir().expect("outside root"); + symlink(outside.path(), directory.path().join("link")).expect("outside symlink"); + let request = orca_core::tool_types::ToolRequest { + id: "parent-edit".to_string(), + name: orca_core::tool_types::ToolName::Edit, + action: orca_core::approval_types::ActionKind::Write, + target: Some("link/../src/item.py".to_string()), + raw_arguments: Some( + r#"{"path":"link/../src/item.py","old_text":"value = 2","new_text":"value = 3"}"# + .to_string(), + ), + }; + let result = orca_tools::edit::execute(&request, directory.path()); + assert_eq!(result.status, orca_core::tool_types::ToolStatus::Completed); + let preview = result + .file_change_preview + .as_deref() + .expect("committed parent preview"); + let orca_core::tool_types::FileChangePreview::UnifiedDiff { text: diff, .. } = preview + else { + panic!("parent edit should produce unified diff"); + }; + state.update(TuiEvent::ToolRequested { + id: request.id.clone(), + name: "edit".to_string(), + target: request.target.clone(), + }); + state.update(TuiEvent::ToolCompleted { + id: request.id, + name: "edit".to_string(), + status: "completed".to_string(), + output: result.output.unwrap_or_default(), + diff: Some(diff.clone()), + kind: None, + }); + + let job = state + .pending_edit_highlight_job("parent-edit") + .expect("parent edit pending job"); + assert_eq!( + job.absolute_path, + directory + .path() + .join("src/item.py") + .canonicalize() + .expect("canonical item") + ); + assert_eq!(job.display_path, "src/item.py"); + assert_eq!(job.parsed.destination_path.as_deref(), Some("src/item.py")); + } + + #[test] + fn real_edit_producer_allows_parent_reentry_into_same_workspace() { + let (directory, mut state) = configured_edit_state(); + let workspace_name = directory + .path() + .file_name() + .and_then(|name| name.to_str()) + .expect("utf-8 workspace name"); + let target = format!("../{workspace_name}/src/item.py"); + let request = orca_core::tool_types::ToolRequest { + id: "parent-reentry-edit".to_string(), + name: orca_core::tool_types::ToolName::Edit, + action: orca_core::approval_types::ActionKind::Write, + target: Some(target.clone()), + raw_arguments: Some(format!( + r#"{{"path":"{target}","old_text":"value = 2","new_text":"value = 3"}}"# + )), + }; + let result = orca_tools::edit::execute(&request, directory.path()); + assert_eq!(result.status, orca_core::tool_types::ToolStatus::Completed); + let preview = result + .file_change_preview + .as_deref() + .expect("committed parent-reentry preview"); + let orca_core::tool_types::FileChangePreview::UnifiedDiff { text: diff, .. } = preview + else { + panic!("parent-reentry edit should produce unified diff"); + }; + let parsed = crate::diff_highlight::parse_unified_diff(diff); + assert_eq!(parsed.destination_path.as_deref(), Some("src/item.py")); + state.update(TuiEvent::ToolRequested { + id: request.id.clone(), + name: "edit".to_string(), + target: request.target.clone(), + }); + state.update(TuiEvent::ToolCompleted { + id: request.id, + name: "edit".to_string(), + status: "completed".to_string(), + output: result.output.unwrap_or_default(), + diff: Some(diff.clone()), + kind: None, + }); + + let job = state + .pending_edit_highlight_job("parent-reentry-edit") + .expect("parent-reentry pending job"); + assert_eq!( + job.absolute_path, + directory + .path() + .join("src/item.py") + .canonicalize() + .expect("canonical item") + ); + assert_eq!(job.display_path, "src/item.py"); + assert_eq!(job.parsed.destination_path.as_deref(), Some("src/item.py")); + } + + #[cfg(unix)] + #[test] + fn app_target_resolution_matches_tool_resolution_table() { + use std::os::unix::fs::symlink; + + let (directory, state) = configured_edit_state(); + std::fs::write(directory.path().join("item.py"), "value = 2\n").expect("root item"); + symlink( + directory.path().join("src/item.py"), + directory.path().join("src/alias.py"), + ) + .expect("alias symlink"); + let outside = tempfile::tempdir().expect("outside root"); + std::fs::create_dir(outside.path().join("child")).expect("outside child"); + std::fs::write(outside.path().join("escaped.py"), "value = 2\n").expect("outside file"); + symlink( + outside.path().join("child"), + directory.path().join("linked"), + ) + .expect("outside child symlink"); + + let workspace_name = directory + .path() + .file_name() + .and_then(|name| name.to_str()) + .expect("utf-8 workspace name"); + let parent_reentry = format!("../{workspace_name}/src/item.py"); + let cases = vec![ + ("src/item.py".to_string(), Some("src/item.py")), + ("src/../item.py".to_string(), Some("item.py")), + ("src/alias.py".to_string(), Some("src/alias.py")), + (parent_reentry, Some("src/item.py")), + ("../escaped.py".to_string(), None), + ("linked/escaped.py".to_string(), None), + ]; + + for (target, expected_display) in cases { + let tool_path = orca_tools::resolve_workspace_path(directory.path(), Some(&target)) + .ok() + .filter(|path| path.is_file()) + .and_then(|path| path.canonicalize().ok()); + let app_path = state.resolve_edit_target(&target); + assert_eq!(app_path.is_some(), tool_path.is_some(), "{target}"); + if let (Some((app_absolute, display)), Some(tool_absolute)) = (app_path, tool_path) { + assert_eq!(app_absolute, tool_absolute, "{target}"); + assert_eq!( + display, + expected_display.expect("expected display"), + "{target}" + ); + } + } + } + + #[test] + fn reversed_file_headers_are_rejected_before_runtime_spawn() { + let (_directory, mut state) = configured_edit_state(); + let reversed = "\ ++++ b/src/item.py +--- a/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +"; + + submit_live_edit(&mut state, "reversed", "src/item.py", reversed); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn arbitrary_in_hunk_and_trailing_metadata_are_rejected() { + for (id, diff) in [ + ( + "leading-metadata", + "\ +arbitrary metadata +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +", + ), + ( + "in-hunk-metadata", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,2 +1,2 @@ +-value = 1 ++value = 2 +arbitrary metadata + shared = 3 +", + ), + ( + "trailing-metadata", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1 @@ +-value = 1 ++value = 2 +arbitrary metadata +", + ), + ] { + let (_directory, mut state) = configured_edit_state(); + + submit_live_edit(&mut state, id, "src/item.py", diff); + + assert_eq!(state.pending_edit_highlight_count(), 0, "{id}"); + assert!(!state.edit_highlight_runtime_started(), "{id}"); + } + } + + #[test] + fn standard_no_newline_metadata_and_header_timestamps_are_allowed() { + let (_directory, mut state) = configured_edit_state(); + let diff = "\ +--- a/src/item.py\t2026-07-24 10:00:00 ++++ b/src/item.py\t2026-07-24 10:01:00 +@@ -1 +1 @@ +-value = 1 +\\ No newline at end of file ++value = 2 +\\ No newline at end of file +"; + + submit_live_edit(&mut state, "standard-metadata", "src/item.py", diff); + + assert_eq!(state.pending_edit_highlight_count(), 1); + assert!( + state + .pending_edit_highlight_job("standard-metadata") + .is_some() + ); + } + + #[test] + fn zero_new_side_coordinate_is_rejected_before_runtime_spawn() { + let (_directory, mut state) = configured_edit_state(); + let invalid = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -0,0 +0,1 @@ ++value = 2 +"; + + submit_live_edit(&mut state, "zero-new-line", "src/item.py", invalid); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn positive_count_with_zero_start_is_rejected_before_runtime_spawn() { + let (_directory, mut state) = configured_edit_state(); + let invalid = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -0 +1 @@ +-value = 1 ++value = 2 +"; + + submit_live_edit(&mut state, "zero-old-start", "src/item.py", invalid); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn positive_start_zero_count_mid_file_insertion_is_eligible() { + let (directory, mut state) = configured_edit_state(); + std::fs::write( + directory.path().join("src/item.py"), + "first = 1\nvalue = 2\n", + ) + .expect("post-insert file"); + let insertion = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,0 +2 @@ fn context ++value = 2 +"; + + submit_live_edit(&mut state, "mid-insert", "src/item.py", insertion); + + assert_eq!(state.pending_edit_highlight_count(), 1); + assert!(state.pending_edit_highlight_job("mid-insert").is_some()); + } + + #[test] + fn empty_zero_width_hunk_is_rejected_before_runtime_spawn() { + let (_directory, mut state) = configured_edit_state(); + let diff = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,0 +1,0 @@ +@@ -1 +1 @@ +-value = 1 ++value = 2 +"; + + submit_live_edit(&mut state, "empty-hunk", "src/item.py", diff); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn duplicate_backward_and_overlapping_hunks_are_rejected() { + let cases = [ + ( + "duplicate-hunk", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1 @@ +-old = 1 ++value = 2 +@@ -1 +1 @@ +-old = 1 ++value = 2 +", + ), + ( + "backward-hunk", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -3 +3 @@ +-old = 3 ++new = 3 +@@ -1 +1 @@ +-old = 1 ++value = 2 +", + ), + ( + "old-overlap", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,2 +1 @@ +-old = 1 +-old = 2 ++new = 1 +@@ -2 +2 @@ +-old = 2 ++value = 2 +", + ), + ( + "new-overlap", + "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1 +1,2 @@ +-old = 1 ++new = 1 ++value = 2 +@@ -2 +2 @@ +-old = 2 ++value = 2 +", + ), + ]; + + for (id, diff) in cases { + let (_directory, mut state) = configured_edit_state(); + + submit_live_edit(&mut state, id, "src/item.py", diff); + + assert_eq!(state.pending_edit_highlight_count(), 0, "{id}"); + assert!(!state.edit_highlight_runtime_started(), "{id}"); + } + } + + #[test] + fn overflowing_hunk_endpoint_is_rejected() { + let (_directory, mut state) = configured_edit_state(); + let diff = format!( + "--- a/src/item.py\n+++ b/src/item.py\n@@ -{},2 +1,2 @@\n-old = 1\n-old = 2\n+value = 2\n+new = 2\n", + usize::MAX + ); + + submit_live_edit(&mut state, "overflowing-hunk", "src/item.py", &diff); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn two_non_overlapping_hunks_with_function_context_are_eligible() { + let (directory, mut state) = configured_edit_state(); + std::fs::write( + directory.path().join("src/item.py"), + "first = 1\nvalue = 2\nthird = 3\nvalue = 4\n", + ) + .expect("two-hunk post-edit file"); + let diff = "\ +--- a/src/item.py ++++ b/src/item.py +@@ -1,2 +1,2 @@ first section + first = 1 +-old = 1 ++value = 2 +@@ -3,2 +3,2 @@ second section + third = 3 +-old = 2 ++value = 4 +"; + + submit_live_edit(&mut state, "two-hunks", "src/item.py", diff); + + assert_eq!(state.pending_edit_highlight_count(), 1); + assert!(state.pending_edit_highlight_job("two-hunks").is_some()); + } + + #[test] + fn dev_null_counts_correlate_and_delete_only_stays_ineligible() { + let (_directory, mut state) = configured_edit_state(); + let malformed_add = "\ +--- /dev/null ++++ b/src/item.py +@@ -1 +1 @@ +-old ++value = 2 +"; + let delete = "\ +--- a/src/item.py ++++ /dev/null +@@ -1 +0,0 @@ +-value = 1 +"; + + submit_live_edit(&mut state, "malformed-add", "src/item.py", malformed_add); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!parsed_diff_structure_matches_target( + &crate::diff_highlight::parse_unified_diff(malformed_add), + malformed_add, + Path::new("src/item.py") + )); + + assert!(parsed_diff_structure_matches_target( + &crate::diff_highlight::parse_unified_diff(delete), + delete, + Path::new("src/item.py") + )); + submit_live_edit(&mut state, "delete", "src/item.py", delete); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn absolute_empty_mismatch_and_symlink_escape_targets_are_rejected() { + let (directory, mut state) = configured_edit_state(); + let absolute = directory.path().join("src/item.py"); + submit_live_edit( + &mut state, + "absolute", + absolute.to_str().expect("utf-8 absolute path"), + EDIT_DIFF, + ); + assert!(state.pending_edit_highlight_job("absolute").is_none()); + assert!(!state.edit_highlight_runtime_started()); + + submit_live_edit(&mut state, "empty", "", EDIT_DIFF); + assert!(state.pending_edit_highlight_job("empty").is_none()); + assert!(!state.edit_highlight_runtime_started()); + + submit_live_edit( + &mut state, + "mismatch", + "src/item.py", + &EDIT_DIFF.replace("src/item.py", "src/other.py"), + ); + assert!(state.pending_edit_highlight_job("mismatch").is_none()); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let alias_parent = tempfile::tempdir().expect("workspace alias parent"); + let workspace_alias = alias_parent.path().join("workspace"); + symlink(directory.path(), &workspace_alias).expect("workspace symlink"); + state.configure_syntax_highlighting( + workspace_alias, + crate::syntax_highlight::SyntaxTheme::OneHalfDark, + crate::terminal_capabilities::TerminalColorLevel::TrueColor, + ); + let outside = tempfile::tempdir().expect("outside directory"); + std::fs::write(outside.path().join("escaped.py"), "value = 2\n").expect("outside file"); + symlink(outside.path(), directory.path().join("linked")).expect("outside symlink"); + submit_live_edit( + &mut state, + "symlink-ancestor", + "linked/escaped.py", + &EDIT_DIFF.replace("src/item.py", "linked/escaped.py"), + ); + assert!(!state.edit_highlight_runtime_started()); + } + } + + #[test] + fn targetless_tool_request_and_completion_submit_no_job() { + let (_directory, mut state) = configured_edit_state(); + state.update(TuiEvent::ToolRequested { + id: "targetless".to_string(), + name: "edit".to_string(), + target: None, + }); + state.update(TuiEvent::ToolCompleted { + id: "targetless".to_string(), + name: "edit".to_string(), + status: "completed".to_string(), + output: "edited".to_string(), + diff: Some(EDIT_DIFF.to_string()), + kind: None, + }); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn completion_with_only_settled_reused_id_pushes_targetless_row() { + for old_status in ["completed", "failed"] { + let (_directory, mut state) = configured_edit_state(); + state.push_message(ChatMessage::ToolCall { + id: "reused".to_string(), + name: "edit".to_string(), + target: Some("src/item.py".to_string()), + status: old_status.to_string(), + output: Some("old output".to_string()), + diff: Some(EDIT_DIFF.to_string()), + kind: None, + expanded: false, + }); + + state.update(TuiEvent::ToolCompleted { + id: "reused".to_string(), + name: "edit".to_string(), + status: "completed".to_string(), + output: "new output".to_string(), + diff: Some(EDIT_DIFF.to_string()), + kind: None, + }); + + assert_eq!(state.messages.len(), 2, "{old_status}"); + assert!(matches!( + &state.messages[0], + ChatMessage::ToolCall { + target: Some(target), + status, + output: Some(output), + .. + } if target == "src/item.py" && status == old_status && output == "old output" + )); + assert!(matches!( + &state.messages[1], + ChatMessage::ToolCall { + target: None, + status, + output: Some(output), + .. + } if status == "completed" && output == "new output" + )); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + } + + #[test] + fn injected_worker_spawn_failure_is_silent_and_leaves_no_pending_state() { + fn fail_runtime() -> std::io::Result { + Err(std::io::Error::other("injected spawn failure")) + } + + let (_directory, mut state) = configured_edit_state(); + state.set_edit_highlight_runtime_factory_for_test(fail_runtime); + submit_live_edit(&mut state, "edit-1", "src/item.py", EDIT_DIFF); + + assert_eq!(state.messages.len(), 1); + assert!(matches!( + state.messages.first(), + Some(ChatMessage::ToolCall { id, .. }) if id == "edit-1" + )); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn exact_ready_result_touches_only_matching_message_and_stores_arc_map() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + state.push_message(ChatMessage::System("unrelated".to_string())); + let revisions_before = state.message_revisions.clone(); + let result = ready_result(job.clone()); + let expected_styles = match &result.outcome { + crate::edit_highlight_worker::EditHighlightOutcome::Ready { styles } => { + Arc::clone(styles) + } + crate::edit_highlight_worker::EditHighlightOutcome::Failed => unreachable!(), + }; + + assert!(state.apply_edit_highlight_result(result)); + assert_ne!( + state.message_revisions[job.message_index], + revisions_before[job.message_index] + ); + assert_eq!( + state.message_revisions[job.message_index + 1], + revisions_before[job.message_index + 1] + ); + assert!(Arc::ptr_eq( + state + .applied_diff_highlights + .get(&state.message_revisions[job.message_index]) + .map(|highlight| &highlight.styles) + .expect("applied styles"), + &expected_styles + )); + assert_eq!( + state.applied_diff_highlights[&state.message_revisions[job.message_index]].tool_id, + job.tool_id + ); + assert_eq!( + state.applied_diff_highlights[&state.message_revisions[job.message_index]].display_path, + job.display_path + ); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_some() + ); + assert_eq!(state.pending_edit_highlight_count(), 0); + } + + #[test] + fn refinement_rebuilds_only_matching_message_then_steady_and_scroll_build_nothing() { + use std::cell::RefCell; + + let (_directory, mut state, job) = state_with_submitted_edit_job(); + state.push_message(ChatMessage::System("stable".to_string())); + let stable_index = job.message_index + 1; + let theme = crate::theme::Theme::named(orca_core::config::ThemeName::Dark); + let built_indices = RefCell::new(Vec::new()); + + { + let messages = &state.messages; + let revisions = &state.message_revisions; + let highlights = &state.applied_diff_highlights; + let cache = &mut state.transcript_render_cache; + cache.prepare( + messages, + revisions, + TranscriptRenderContext::new(&theme, 80, 0, false), + |index, message, theme, width, tick, force_expand| { + built_indices.borrow_mut().push(index); + let refined = AppState::refined_diff_styles_for_message( + revisions, highlights, index, message, + ); + crate::ui::build_lines_for_message( + message, + theme, + width, + tick, + force_expand, + refined, + ) + }, + ); + } + assert_eq!( + *built_indices.borrow(), + vec![job.message_index, stable_index] + ); + let revisions_before = state.message_revisions.clone(); + built_indices.borrow_mut().clear(); + + assert!(state.apply_edit_highlight_result(ready_result(job.clone()))); + { + let messages = &state.messages; + let revisions = &state.message_revisions; + let highlights = &state.applied_diff_highlights; + let cache = &mut state.transcript_render_cache; + cache.prepare( + messages, + revisions, + TranscriptRenderContext::new(&theme, 80, 0, false), + |index, message, theme, width, tick, force_expand| { + built_indices.borrow_mut().push(index); + let refined = AppState::refined_diff_styles_for_message( + revisions, highlights, index, message, + ); + crate::ui::build_lines_for_message( + message, + theme, + width, + tick, + force_expand, + refined, + ) + }, + ); + } + + assert_eq!(*built_indices.borrow(), vec![job.message_index]); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 1); + assert_eq!( + state.message_revisions[stable_index], + revisions_before[stable_index] + ); + let viewport = state + .transcript_render_cache + .viewport(0, usize::MAX, usize::MAX); + let inserted = inserted_source_line(&viewport.lines, "value = 2"); + assert!( + inserted + .spans + .iter() + .any(|span| { span.style.fg == Some(ratatui::style::Color::Magenta) }) + ); + + built_indices.borrow_mut().clear(); + { + let messages = &state.messages; + let revisions = &state.message_revisions; + let highlights = &state.applied_diff_highlights; + let cache = &mut state.transcript_render_cache; + cache.prepare( + messages, + revisions, + TranscriptRenderContext::new(&theme, 80, 0, false), + |index, message, theme, width, tick, force_expand| { + built_indices.borrow_mut().push(index); + let refined = AppState::refined_diff_styles_for_message( + revisions, highlights, index, message, + ); + crate::ui::build_lines_for_message( + message, + theme, + width, + tick, + force_expand, + refined, + ) + }, + ); + } + assert!(built_indices.borrow().is_empty()); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 0); + + let _ = state.transcript_render_cache.viewport(0, 0, 1); + let _ = state + .transcript_render_cache + .viewport(0, usize::MAX, usize::MAX); + assert!(built_indices.borrow().is_empty()); + assert_eq!(state.transcript_render_cache.last_prepare_visited(), 0); + } + + #[test] + fn real_worker_result_becomes_exact_message_styles_and_warms_rendering() { + const SCOPED_DIFF: &str = "\ +--- a/item.py ++++ b/item.py +@@ -3,2 +3,2 @@ + \"\"\" +- field = 0 ++ field = 1 +"; + let directory = tempfile::tempdir().expect("scoped edit workspace"); + std::fs::write( + directory.path().join("item.py"), + "\ +class Item: + \"\"\"Summary. + \"\"\" + field = 1 +", + ) + .expect("post-edit Python file"); + let mut state = state(); + state.configure_syntax_highlighting( + directory.path().to_path_buf(), + crate::syntax_highlight::SyntaxTheme::OneHalfDark, + crate::terminal_capabilities::TerminalColorLevel::TrueColor, + ); + submit_live_edit(&mut state, "scoped-edit", "item.py", SCOPED_DIFF); + let theme = crate::theme::Theme::named(orca_core::config::ThemeName::Dark); + let cold = + crate::ui::build_lines_for_message(&state.messages[0], &theme, 80, 0, false, None); + + let deadline = Instant::now() + std::time::Duration::from_secs(2); + loop { + if state.poll_edit_highlight_results() { + break; + } + assert!( + state.edit_highlight_needs_tick(), + "worker stopped pending without applying a result" + ); + assert!( + Instant::now() < deadline, + "worker did not return before the bounded deadline" + ); + std::thread::yield_now(); + } + + let refined = state + .refined_diff_styles(0, "scoped-edit") + .expect("exact message refinement"); + assert!(refined.contains_key(&3)); + assert!(refined.contains_key(&4)); + let warm = crate::ui::build_lines_for_message( + &state.messages[0], + &theme, + 80, + 0, + false, + Some(refined), + ); + let cold_field = inserted_source_line(&cold, " field = 1"); + let warm_field = inserted_source_line(&warm, " field = 1"); + + assert_ne!(warm_field.spans[1..], cold_field.spans[1..]); + assert_eq!( + normalized_source_spans(&warm_field.spans[1..]), + normalized_source_spans(&refined[&4]) + ); + assert!(!state.edit_highlight_needs_tick()); + } + + #[test] + fn failed_result_finishes_pending_without_touching_or_noise() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + let revisions_before = state.message_revisions.clone(); + let messages_before = state.messages.len(); + + assert!(!state.apply_edit_highlight_result( + crate::edit_highlight_worker::EditHighlightResult { + job: job.clone(), + outcome: crate::edit_highlight_worker::EditHighlightOutcome::Failed, + } + )); + + assert_eq!(state.pending_edit_highlight_count(), 0); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + assert_eq!(state.message_revisions, revisions_before); + assert_eq!(state.messages.len(), messages_before); + } + + #[test] + fn stale_edit_highlight_identity_is_rejected_without_touching_message() { + type Mutation = + Box; + let mutations: Vec = vec![ + Box::new(|state, job| { + state.touch_message(job.message_index); + }), + Box::new(|_, job| job.job_id += 1), + Box::new(|_, job| job.message_index += 1), + Box::new(|_, job| job.tool_id = "other-tool".to_string()), + Box::new(|_, job| job.absolute_path = PathBuf::from("/other/item.py")), + Box::new(|_, job| job.display_path = "src/other.py".to_string()), + Box::new(|state, _| { + state.syntax_theme = crate::syntax_highlight::SyntaxTheme::OneHalfLight; + }), + Box::new(|_, job| { + job.syntax_theme_revision = crate::terminal_capabilities::syntax_style_revision( + crate::syntax_highlight::SyntaxTheme::OneHalfLight, + job.syntax_color_level, + ); + }), + Box::new(|state, job| { + let ChatMessage::ToolCall { diff, .. } = &mut state.messages[job.message_index] + else { + unreachable!(); + }; + *diff = Some(EDIT_DIFF.replace("value = 2", "value = 3")); + }), + Box::new(|state, job| { + let ChatMessage::ToolCall { target, .. } = &mut state.messages[job.message_index] + else { + unreachable!(); + }; + *target = Some("src/other.py".to_string()); + }), + ]; + + for mutate in mutations { + let (_directory, mut state, mut job) = state_with_submitted_edit_job(); + mutate(&mut state, &mut job); + let revisions_before = state.message_revisions.clone(); + + assert!(!state.apply_edit_highlight_result(ready_result(job.clone()))); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + assert_eq!(state.message_revisions, revisions_before); + } + } + + #[test] + fn stale_edit_highlight_is_rejected_when_only_syntax_color_level_changes() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + let syntax_theme = state.syntax_theme; + state.syntax_color_level = crate::terminal_capabilities::TerminalColorLevel::Ansi256; + let revisions_before = state.message_revisions.clone(); + + assert_eq!(state.syntax_theme, syntax_theme); + assert_ne!(state.syntax_color_level, job.syntax_color_level); + assert_ne!( + crate::terminal_capabilities::syntax_style_revision( + state.syntax_theme, + state.syntax_color_level, + ), + job.syntax_theme_revision + ); + assert!(!state.apply_edit_highlight_result(ready_result(job.clone()))); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + assert_eq!(state.message_revisions, revisions_before); + } + + #[test] + fn ready_result_rejects_current_failed_status_without_touching() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + let ChatMessage::ToolCall { status, .. } = &mut state.messages[job.message_index] else { + unreachable!(); + }; + *status = "failed".to_string(); + let revisions = state.message_revisions.clone(); + + assert!(!state.apply_edit_highlight_result(ready_result(job.clone()))); + assert_eq!(state.message_revisions, revisions); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + } + + #[test] + fn ready_result_rejects_current_row_tool_id_and_finishes_pending() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + let ChatMessage::ToolCall { id, .. } = &mut state.messages[job.message_index] else { + unreachable!(); + }; + *id = "different-current-id".to_string(); + let revisions = state.message_revisions.clone(); + + assert!(!state.apply_edit_highlight_result(ready_result(job.clone()))); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert_eq!(state.message_revisions, revisions); + assert!(state.applied_diff_highlights.is_empty()); + } + + #[test] + fn ready_result_rejects_current_diff_destination_mismatch() { + let (directory, mut state, job) = state_with_submitted_edit_job(); + std::fs::write(directory.path().join("src/other.py"), "value = 2\n") + .expect("other post-edit file"); + let ChatMessage::ToolCall { diff, .. } = &mut state.messages[job.message_index] else { + unreachable!(); + }; + *diff = Some(EDIT_DIFF.replace("src/item.py", "src/other.py")); + let revisions = state.message_revisions.clone(); + + assert!(!state.apply_edit_highlight_result(ready_result(job.clone()))); + assert_eq!(state.message_revisions, revisions); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + } + + #[cfg(unix)] + #[test] + fn ready_result_rejects_retargeted_symlink_on_apply_path() { + use std::os::unix::fs::symlink; + + let (directory, mut state, job) = real_alias_edit_state(); + std::fs::write(directory.path().join("src/other.py"), "value = 2\n") + .expect("other post-edit file"); + let alias = directory.path().join("src/alias.py"); + assert_eq!(job.display_path, "src/alias.py"); + std::fs::remove_file(&alias).expect("remove initial alias"); + symlink(directory.path().join("src/other.py"), &alias).expect("retarget alias"); + let revisions = state.message_revisions.clone(); + + assert!(!state.apply_edit_highlight_result(ready_result(job.clone()))); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert_eq!(state.message_revisions, revisions); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + } + + #[test] + fn stale_result_does_not_remove_newer_pending_job_for_same_tool() { + let (_directory, mut state, stale_job) = state_with_submitted_edit_job(); + submit_live_edit(&mut state, "edit-1", "src/item.py", EDIT_DIFF); + let newer_job = state + .pending_edit_highlight_job("edit-1") + .expect("newer pending job"); + assert_ne!(stale_job.job_id, newer_job.job_id); + + assert!(!state.apply_edit_highlight_result(ready_result(stale_job))); + assert_eq!( + state + .pending_edit_highlight_job("edit-1") + .expect("newer job preserved") + .job_id, + newer_job.job_id + ); + } + + #[test] + fn touch_mutate_and_replace_cancel_only_their_exact_pending_message() { + for action in ["touch", "mutate", "replace"] { + let (_directory, mut state) = configured_edit_state(); + submit_live_edit(&mut state, "edit-a", "src/item.py", EDIT_DIFF); + let job_a = state + .pending_edit_highlight_job("edit-a") + .expect("pending A"); + submit_live_edit(&mut state, "edit-b", "src/item.py", EDIT_DIFF); + let job_b = state + .pending_edit_highlight_job("edit-b") + .expect("pending B"); + assert_eq!(state.pending_edit_highlight_count(), 2); + + match action { + "touch" => { + assert!(state.touch_message(job_a.message_index)); + } + "mutate" => { + state + .mutate_message(job_a.message_index, |message| { + let ChatMessage::ToolCall { expanded, .. } = message else { + unreachable!(); + }; + *expanded = true; + }) + .expect("mutate A"); + } + "replace" => { + let replacement = state.messages[job_a.message_index].clone(); + assert!(state.replace_message(job_a.message_index, replacement)); + } + _ => unreachable!(), + } + + assert!( + state.pending_edit_highlight_job("edit-a").is_none(), + "{action}" + ); + assert_eq!( + state + .pending_edit_highlight_job("edit-b") + .expect("unrelated B remains") + .job_id, + job_b.job_id, + "{action}" + ); + assert_eq!(state.pending_edit_highlight_count(), 1, "{action}"); + assert!(state.edit_highlight_needs_tick(), "{action}"); + assert!(state.apply_edit_highlight_result(ready_result(job_b.clone()))); + assert!( + state + .refined_diff_styles(job_b.message_index, &job_b.tool_id) + .is_some() + ); + } + } + + #[test] + fn replacing_non_tool_message_keeps_unrelated_edit_pending() { + let (_directory, mut state) = configured_edit_state(); + state.push_message(ChatMessage::Reasoning("old".to_string())); + submit_live_edit(&mut state, "edit-a", "src/item.py", EDIT_DIFF); + let job = state + .pending_edit_highlight_job("edit-a") + .expect("pending edit"); + + assert!(state.replace_message(0, ChatMessage::Reasoning("new".to_string()))); + + assert_eq!( + state + .pending_edit_highlight_job("edit-a") + .expect("edit pending survives") + .job_id, + job.job_id + ); + assert!(state.edit_highlight_needs_tick()); + assert!(state.apply_edit_highlight_result(ready_result(job.clone()))); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_some() + ); + } + + #[test] + fn disconnected_worker_is_abandoned_silently_and_next_edit_respawns() { + fn disconnected( + _runtime: &mut crate::edit_highlight_worker::EditHighlightRuntime, + ) -> crate::edit_highlight_worker::DrainResults { + crate::edit_highlight_worker::DrainResults { + results: Vec::new(), + disconnected: true, + } + } + + let (_directory, mut state, _job) = state_with_submitted_edit_job(); + let revisions_before = state.message_revisions.clone(); + let messages_before = state.messages.len(); + state.set_edit_highlight_drain_for_test(Some(disconnected)); + + assert!(!state.poll_edit_highlight_results()); + assert!(!state.edit_highlight_runtime_started()); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert_eq!(state.message_revisions, revisions_before); + assert_eq!(state.messages.len(), messages_before); + + state.set_edit_highlight_drain_for_test(None); + submit_live_edit(&mut state, "edit-2", "src/item.py", EDIT_DIFF); + assert!(state.edit_highlight_runtime_started()); + assert_eq!(state.pending_edit_highlight_count(), 1); + } + + #[test] + fn tool_touch_mutate_and_replace_remove_applied_map_before_revision_change() { + for action in ["touch", "mutate", "replace"] { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + assert!(state.apply_edit_highlight_result(ready_result(job.clone()))); + let revision = state.message_revisions[job.message_index]; + + match action { + "touch" => { + state.touch_message(job.message_index); + } + "mutate" => { + state.mutate_message(job.message_index, |message| { + let ChatMessage::ToolCall { expanded, .. } = message else { + unreachable!(); + }; + *expanded = true; + }); + } + "replace" => { + let replacement = state.messages[job.message_index].clone(); + state.replace_message(job.message_index, replacement); + } + _ => unreachable!(), + } + + assert!(state.message_revisions[job.message_index] > revision); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + } + } + + #[test] + fn message_lifecycle_prunes_applied_maps_and_pending_jobs() { + for action in ["clear", "replace", "truncate", "retain"] { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + assert!(state.apply_edit_highlight_result(ready_result(job.clone()))); + submit_live_edit(&mut state, "edit-2", "src/item.py", EDIT_DIFF); + assert_eq!(state.pending_edit_highlight_count(), 1); + + match action { + "clear" => state.clear_messages(), + "replace" => state.replace_messages([ChatMessage::System("new".to_string())]), + "truncate" => state.truncate_messages(job.message_index), + "retain" => state + .retain_messages(|message| !matches!(message, ChatMessage::ToolCall { .. })), + _ => unreachable!(), + } + + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + assert_eq!(state.pending_edit_highlight_count(), 0); + } + } + + #[test] + fn retained_reindexing_clears_all_pending_jobs_conservatively() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + state + .messages + .insert(0, ChatMessage::System("remove".to_string())); + state.reconcile_message_tracking(); + assert_eq!(state.pending_edit_highlight_count(), 1); + + state.retain_messages( + |message| !matches!(message, ChatMessage::System(text) if text == "remove"), + ); + + assert_eq!(state.pending_edit_highlight_count(), 0); + let revisions = state.message_revisions.clone(); + assert!(!state.apply_edit_highlight_result(ready_result(job.clone()))); + assert_eq!(state.message_revisions, revisions); + assert!( + state + .refined_diff_styles(job.message_index, &job.tool_id) + .is_none() + ); + } + + #[test] + fn removed_message_result_and_reused_identity_never_inherit_styles() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + state.clear_messages(); + state.push_message(ChatMessage::ToolCall { + id: job.tool_id.clone(), + name: "edit".to_string(), + target: Some(job.display_path.clone()), + status: "completed".to_string(), + output: None, + diff: Some(EDIT_DIFF.to_string()), + kind: None, + expanded: false, + }); + + assert!(!state.apply_edit_highlight_result(ready_result(job.clone()))); + assert!(state.refined_diff_styles(0, &job.tool_id).is_none()); + } + + #[test] + fn direct_push_with_reused_tool_id_does_not_inherit_applied_styles() { + let (_directory, mut state, job) = state_with_submitted_edit_job(); + assert!(state.apply_edit_highlight_result(ready_result(job.clone()))); + + state.push_message(ChatMessage::ToolCall { + id: job.tool_id.clone(), + name: "edit".to_string(), + target: Some(job.display_path.clone()), + status: "running".to_string(), + output: None, + diff: None, + kind: None, + expanded: false, + }); + + assert!(state.refined_diff_styles(0, &job.tool_id).is_none()); + assert!(state.refined_diff_styles(1, &job.tool_id).is_none()); + } + + #[test] + fn duplicate_tool_id_map_is_bound_to_exact_message_revision() { + let (_directory, mut state) = configured_edit_state(); + state.push_message(ChatMessage::ToolCall { + id: "duplicate".to_string(), + name: "edit".to_string(), + target: Some("src/item.py".to_string()), + status: "completed".to_string(), + output: Some("older".to_string()), + diff: Some(EDIT_DIFF.to_string()), + kind: None, + expanded: false, + }); + submit_live_edit(&mut state, "duplicate", "src/item.py", EDIT_DIFF); + let job = state + .pending_edit_highlight_job("duplicate") + .expect("newer duplicate job"); + assert_eq!(job.message_index, 1); + + assert!(state.apply_edit_highlight_result(ready_result(job.clone()))); + assert!( + AppState::refined_diff_styles_for_message( + &state.message_revisions, + &state.applied_diff_highlights, + 0, + &state.messages[0], + ) + .is_none() + ); + assert!( + AppState::refined_diff_styles_for_message( + &state.message_revisions, + &state.applied_diff_highlights, + 1, + &state.messages[1], + ) + .is_some() + ); + + state.truncate_messages(1); + + assert!(state.refined_diff_styles(0, "duplicate").is_none()); + assert!(state.applied_diff_highlights.is_empty()); + } + + #[test] + fn partial_prune_keeps_unrelated_applied_revision() { + let (_directory, mut state) = configured_edit_state(); + submit_live_edit(&mut state, "edit-a", "src/item.py", EDIT_DIFF); + let job_a = state + .pending_edit_highlight_job("edit-a") + .expect("pending A"); + submit_live_edit(&mut state, "edit-b", "src/item.py", EDIT_DIFF); + let job_b = state + .pending_edit_highlight_job("edit-b") + .expect("pending B"); + assert!(state.apply_edit_highlight_result(ready_result(job_a))); + assert!(state.apply_edit_highlight_result(ready_result(job_b))); + assert!(state.refined_diff_styles(0, "edit-a").is_some()); + assert!(state.refined_diff_styles(1, "edit-b").is_some()); + + state.truncate_messages(1); + + assert!(state.refined_diff_styles(0, "edit-a").is_some()); + assert_eq!(state.applied_diff_highlights.len(), 1); + } + + #[test] + fn reused_tool_id_live_submission_applies_only_to_new_row() { + let (_directory, mut state, first_job) = state_with_submitted_edit_job(); + assert!(state.apply_edit_highlight_result(ready_result(first_job))); + assert!(state.refined_diff_styles(0, "edit-1").is_some()); + + submit_live_edit(&mut state, "edit-1", "src/item.py", EDIT_DIFF); + let new_job = state + .pending_edit_highlight_job("edit-1") + .expect("new reused job"); + assert_eq!(new_job.message_index, 1); + assert!(state.apply_edit_highlight_result(ready_result(new_job))); + + assert!(state.refined_diff_styles(0, "edit-1").is_none()); + assert!(state.refined_diff_styles(1, "edit-1").is_some()); + } + + #[test] + fn disconnected_job_sender_drops_runtime_without_noise_or_extra_revision() { + fn disconnected_runtime() + -> std::io::Result { + Ok(crate::edit_highlight_worker::EditHighlightRuntime::disconnected_for_test()) + } + + let (_directory, mut state) = configured_edit_state(); + state.set_edit_highlight_runtime_factory_for_test(disconnected_runtime); + state.update(TuiEvent::ToolRequested { + id: "send-failure".to_string(), + name: "edit".to_string(), + target: Some("src/item.py".to_string()), + }); + let revision_before_completion = state.message_revisions[0]; + + state.update(TuiEvent::ToolCompleted { + id: "send-failure".to_string(), + name: "edit".to_string(), + status: "completed".to_string(), + output: "edited src/item.py".to_string(), + diff: Some(EDIT_DIFF.to_string()), + kind: None, + }); + + assert_eq!(state.messages.len(), 1); + assert_eq!( + state.message_revisions[0], + revision_before_completion.saturating_add(1) + ); + assert!(matches!( + &state.messages[0], + ChatMessage::ToolCall { + status, + output: Some(output), + .. + } if status == "completed" && output == "edited src/item.py" + )); + assert_eq!(state.pending_edit_highlight_count(), 0); + assert_eq!(state.successful_edit_highlight_submit_count(), 0); + assert!(!state.edit_highlight_runtime_started()); + } + + #[test] + fn app_state_diagnostics_defaults_are_inert() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let state = AppState::new( + action_tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + + assert!(!state.fps_hud_enabled); + assert_eq!(state.vim_mode, None); + assert_eq!(state.frame_metrics.snapshot(Instant::now()).total_draws, 0,); + } + + #[test] + fn doctor_auth_projection_is_independent_of_ui_status() { + let (action_tx, _action_rx) = mpsc::unbounded(); + let mut state = AppState::new( + action_tx, + "test".to_string(), + "mock".to_string(), + "/tmp".to_string(), + ); + + state.status = AppStatus::Setup; + state.auth_configured = true; + assert!( + state + .doctor_report(Instant::now()) + .contains("auth=configured") + ); + + state.status = AppStatus::Idle; + state.auth_configured = false; + assert!(state.doctor_report(Instant::now()).contains("auth=missing")); + } } diff --git a/crates/orca-tui/src/ui.rs b/crates/orca-tui/src/ui.rs index 1306d359..bc1c205e 100644 --- a/crates/orca-tui/src/ui.rs +++ b/crates/orca-tui/src/ui.rs @@ -1,12 +1,13 @@ -use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; +use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; use ratatui::Frame; -use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Gauge, Paragraph, Wrap}; use std::ops::Range; use std::path::{Path, PathBuf}; use tui_textarea::TextArea; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use orca_core::approval_types::ApprovalMode; @@ -17,11 +18,22 @@ use orca_core::workflow_types::{WorkflowAgentStatus, WorkflowRunStatus}; use orca_file_search::SearchPhase; use orca_runtime::history::SessionSummary; +use crate::diagnostics::FpsHudSnapshot; use crate::display_text::{compact_long_text, truncate_to_display_width}; +use crate::onboarding::{OnboardingOptionRow, OnboardingStep}; +use crate::selection::{TranscriptSelection, apply_style_to_line_range}; use crate::shortcuts::{self, ShortcutScope}; +use crate::syntax_highlight::highlight_code; use crate::theme::Theme; -use crate::transcript_view::viewport_paragraph; -use crate::types::{AppState, AppStatus, ApprovalOption, ChatMessage, PanelMode}; +use crate::transcript_search::TranscriptSearchState; +use crate::transcript_view::{TranscriptRenderContext, viewport_paragraph}; +use crate::types::{AppState, AppStatus, ApprovalOption, ChatMessage, CopyNotice, PanelMode}; +use crate::workspace_status::{GitIdentity, compact_cwd}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct HardwareCursorProjection { + position: Position, +} pub fn render(frame: &mut Frame, state: &mut AppState, textarea: &TextArea, theme: &Theme) { // Recomputed below when the widgets are actually shown; cleared here so @@ -29,20 +41,27 @@ pub fn render(frame: &mut Frame, state: &mut AppState, textarea: &TextArea, them state.jump_to_bottom_area = None; state.frame_area = Some(frame.area()); state.input_area = None; + state.search_area = None; if state.status == AppStatus::Setup { - render_setup(frame, state, textarea, theme); + let hardware_cursor = render_setup(frame, state, textarea, theme); + finish_frame(frame, state, theme, hardware_cursor); return; } if state.status == AppStatus::SessionPicker { render_session_picker(frame, state, theme); + finish_frame(frame, state, theme, None); return; } - let input_height = if composer_visible(state) { - composer_input_height(frame.area().width, textarea) - } else { - 0 - }; + let search_height = u16::from(search_visible(state)); + let show_composer_hardware_cursor = + main_composer_hardware_cursor_visible(state) && search_height == 0; + let composer_layout = composer_visible(state) + .then(|| composer_visual_layout(frame.area().width, textarea, theme)); + let input_height = composer_layout + .as_ref() + .map(|layout| composer_input_height(frame.area().width, textarea, layout)) + .unwrap_or(0); let plan_height = plan_panel_height(state); let goal_height: u16 = if state.current_goal.is_some() { 3 } else { 0 }; @@ -56,12 +75,16 @@ pub fn render(frame: &mut Frame, state: &mut AppState, textarea: &TextArea, them } else { 0 }; + let queue_preview_lines = queued_preview_lines(state, frame.area().width, theme); + let queue_preview_height = queue_preview_lines.len().min(3) as u16; let chunks = main_layout( frame.area(), goal_height, plan_height, activity_height, + queue_preview_height, + search_height, input_height, ); @@ -81,18 +104,36 @@ pub fn render(frame: &mut Frame, state: &mut AppState, textarea: &TextArea, them if activity_height > 0 { render_activity(frame, chunks[3], state, theme); } + if queue_preview_height > 0 { + frame.render_widget(Paragraph::new(queue_preview_lines), chunks[4]); + } + let mut hardware_cursor = None; + if search_height > 0 { + state.search_area = Some(chunks[5]); + hardware_cursor = render_search_bar(frame, chunks[5], state, theme); + } if composer_visible(state) { - state.input_area = Some(chunks[4]); - render_input(frame, chunks[4], textarea, state, theme); + state.input_area = Some(chunks[6]); + let composer_cursor = render_input( + frame, + chunks[6], + textarea, + composer_layout.as_ref().expect("visible composer layout"), + state, + theme, + show_composer_hardware_cursor, + ); + hardware_cursor = hardware_cursor.or(composer_cursor); } - render_status(frame, chunks[5], state, theme); + render_status(frame, chunks[7], state, theme); - if state.slash_menu.is_some() { - render_slash_menu(frame, chunks[4], state, theme); + if !state.transcript_search.open && state.slash_menu.is_some() { + render_slash_menu(frame, chunks[6], state, theme); } - if state.mention.phase.is_some() && state.slash_menu.is_none() { - render_mention_candidates(frame, chunks[4], state, theme); + if !state.transcript_search.open && state.mention.phase.is_some() && state.slash_menu.is_none() + { + render_mention_candidates(frame, chunks[6], state, theme); } if state.status == AppStatus::WaitingApproval { @@ -102,6 +143,7 @@ pub fn render(frame: &mut Frame, state: &mut AppState, textarea: &TextArea, them if state.show_shortcuts { render_shortcuts(frame, state, theme); } + finish_frame(frame, state, theme, hardware_cursor); } fn main_layout( @@ -109,6 +151,8 @@ fn main_layout( goal_height: u16, plan_height: u16, activity_height: u16, + queue_preview_height: u16, + search_height: u16, input_height: u16, ) -> std::rc::Rc<[Rect]> { // The fixed chrome (goal banner, plan, activity line, input box, status line) MUST keep @@ -117,11 +161,21 @@ fn main_layout( // LOWEST, so giving the transcript `Min(5)` made it steal rows from the `Length` chrome // when the transcript overflowed — the input box got squeezed off-screen and the // auto-scrolled tail landed behind it. `Fill(1)` makes the transcript yield instead. + let fixed_without_queue = goal_height + .saturating_add(plan_height) + .saturating_add(activity_height) + .saturating_add(search_height) + .saturating_add(input_height) + .saturating_add(1); + let queue_preview_height = + queue_preview_height.min(area.height.saturating_sub(fixed_without_queue)); Layout::vertical([ Constraint::Length(goal_height), Constraint::Fill(1), Constraint::Length(plan_height), Constraint::Length(activity_height), + Constraint::Length(queue_preview_height), + Constraint::Length(search_height), Constraint::Length(input_height), Constraint::Length(1), ]) @@ -155,6 +209,74 @@ fn composer_visible(state: &AppState) -> bool { !matches!(state.status, AppStatus::WaitingApproval) } +fn main_composer_hardware_cursor_visible(state: &AppState) -> bool { + composer_visible(state) && !state.show_shortcuts +} + +fn search_visible(state: &AppState) -> bool { + state.transcript_search.open + && state.panel_mode == PanelMode::Conversation + && matches!( + state.status, + AppStatus::Idle | AppStatus::Running | AppStatus::WaitingUserInput + ) +} + +fn queued_preview_lines(state: &AppState, width: u16, theme: &Theme) -> Vec> { + if state.panel_mode != PanelMode::Conversation + || !matches!(state.status, AppStatus::Idle | AppStatus::Running) + || !state.queued_follow_up_pending_or_in_flight() + || width == 0 + { + return Vec::new(); + } + let Some(snapshot) = + crate::queued_input::QueuedPreviewSnapshot::from_queue(&state.queued_user_messages) + else { + return Vec::new(); + }; + let width = width as usize; + let header = state.queued_input_error.as_ref().map_or_else( + || format!(" Queued {} · Alt+Up edit latest", snapshot.len), + |error| format!(" Queue error · {error}"), + ); + let header_color = if state.queued_input_error.is_some() { + theme.error + } else { + theme.muted + }; + let mut lines = vec![Line::from(Span::styled( + truncate_to_display_width(&header, width), + Style::default().fg(header_color), + ))]; + let item_style = Style::default() + .fg(theme.muted) + .add_modifier(Modifier::ITALIC); + lines.push(Line::from(Span::styled( + truncate_to_display_width(&format!(" ↳ {}", snapshot.first), width), + item_style, + ))); + if let Some(second) = snapshot.second { + lines.push(Line::from(Span::styled( + truncate_to_display_width(&format!(" ↳ {second}"), width), + item_style, + ))); + } else if let Some(latest) = snapshot.latest { + lines.push(Line::from(Span::styled( + truncate_to_display_width( + &format!( + " … {} more · latest: {latest}", + snapshot.len.saturating_sub(1) + ), + width, + ), + item_style, + ))); + } + lines.truncate(3); + lines +} + fn render_goal_banner(frame: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { use orca_core::goal_types::{ ThreadGoalStatus, format_goal_elapsed_seconds, format_tokens_compact, goal_status_label, @@ -404,12 +526,32 @@ fn highlight_match(text: &str, needle: &str, base: Style, theme: &Theme) -> Vec< /// (PageUp, k/j, etc.) `auto_scroll` clears and the pane honours `scroll_offset`. /// Overlay the mouse selection on materialized transcript rows. The render /// caches stay selection-agnostic so highlighting never invalidates them. -fn apply_selection_overlay( - lines: Vec>, - selection: Option, +fn apply_transcript_overlays( + mut lines: Vec>, + search: &TranscriptSearchState, + selection: Option, base_row: usize, theme: &Theme, ) -> Vec> { + let end_row = base_row.saturating_add(lines.len()); + for (index, found) in search.visible_matches(base_row, end_row) { + let style = if search.active_index() == Some(index) { + theme.search_match_active_style() + } else { + theme.search_match_style() + }; + for absolute_row in found.start.row..=found.last_covered_row() { + let Some(line) = lines.get_mut(absolute_row.saturating_sub(base_row)) else { + continue; + }; + let Some((col_start, col_end)) = found.cols_on_row(absolute_row) else { + continue; + }; + let current = std::mem::take(line); + *line = apply_style_to_line_range(current, col_start, col_end, style); + } + } + let Some(selection) = selection else { return lines; }; @@ -418,12 +560,9 @@ fn apply_selection_overlay( .enumerate() .map( |(index, line)| match selection.cols_on_row(base_row + index) { - Some((col_start, col_end)) => crate::selection::apply_selection_to_line( - line, - col_start, - col_end, - theme.selection_bg, - ), + Some((col_start, col_end)) => { + apply_style_to_line_range(line, col_start, col_end, theme.selection_style()) + } None => line, }, ) @@ -442,6 +581,9 @@ pub(crate) fn render_live_messages( state.transcript_area = Some(area); if state.messages.is_empty() { + state + .transcript_search + .clear_matches(state.transcript_render_cache.content_generation()); // The welcome screen renders through its own cache so its text is // selectable and copyable exactly like transcript content. let lines = build_welcome_lines(state, theme); @@ -453,11 +595,8 @@ pub(crate) fn render_live_messages( state.welcome_render_cache.prepare( &welcome_message, &welcome_revisions, - width, - theme, - state.tick, - false, - |_, _, _, _, _| lines.clone(), + TranscriptRenderContext::new(theme, width, state.tick, false), + |_, _, _, _, _, _| lines.clone(), ); let requested_scroll = if state.auto_scroll { usize::MAX @@ -471,8 +610,13 @@ pub(crate) fn render_live_messages( state.visible_height = visible_height; state.scroll_offset = viewport.scroll_offset; state.viewport_base_row = viewport.base_row; - let lines = - apply_selection_overlay(viewport.lines, state.selection, viewport.base_row, theme); + let lines = apply_transcript_overlays( + viewport.lines, + &state.transcript_search, + state.selection, + viewport.base_row, + theme, + ); frame.render_widget(viewport_paragraph(lines), area); return; } @@ -483,23 +627,24 @@ pub(crate) fn render_live_messages( state.scroll_offset }; let live_start = state.flushed_count.min(state.messages.len()); - state.transcript_render_cache.prepare( - &state.messages, - &state.message_revisions, - width, - theme, - state.tick, - false, - |message, theme, width, tick, force_expand| { - build_lines_for_messages( - std::slice::from_ref(message), - theme, - width, - tick, - force_expand, - ) - }, - ); + let messages = &state.messages; + let revisions = &state.message_revisions; + let highlights = &state.applied_diff_highlights; + { + let cache = &mut state.transcript_render_cache; + cache.prepare( + messages, + revisions, + TranscriptRenderContext::new(theme, width, state.tick, false), + |index, message, theme, width, tick, force_expand| { + let refined = AppState::refined_diff_styles_for_message( + revisions, highlights, index, message, + ); + build_lines_for_message(message, theme, width, tick, force_expand, refined) + }, + ); + } + state.refresh_transcript_search(); let viewport = state .transcript_render_cache @@ -511,7 +656,13 @@ pub(crate) fn render_live_messages( // Overlay the mouse selection on the materialized rows; the render cache // itself stays selection-agnostic so highlighting never invalidates it. - let lines = apply_selection_overlay(viewport.lines, state.selection, viewport.base_row, theme); + let lines = apply_transcript_overlays( + viewport.lines, + &state.transcript_search, + state.selection, + viewport.base_row, + theme, + ); frame.render_widget(viewport_paragraph(lines), area); @@ -534,10 +685,7 @@ pub(crate) fn render_live_messages( height: 1, }; frame.render_widget( - Paragraph::new(Span::styled( - label, - Style::default().bg(theme.selection_bg).fg(theme.text), - )), + Paragraph::new(Span::styled(label, theme.selection_style().fg(theme.text))), pill, ); state.jump_to_bottom_area = Some(pill); @@ -1297,7 +1445,9 @@ fn build_welcome_lines<'a>(state: &AppState, theme: &Theme) -> Vec> { let text = Style::default().fg(theme.text); let muted = Style::default().fg(theme.muted); - vec![ + let composer_tip = welcome_composer_tip(&state.keymap); + let shortcuts_tip = welcome_shortcuts_tip(&state.keymap); + let mut lines = vec![ Line::from(""), Line::from(Span::styled(" ___ ", cyan)), Line::from(Span::styled(" / _ \\ _ __ ___ __ _ ", cyan)), @@ -1318,10 +1468,14 @@ fn build_welcome_lines<'a>(state: &AppState, theme: &Theme) -> Vec> { ]), Line::from(""), Line::from(Span::styled(" Tips", Style::default().fg(theme.success))), - Line::from(Span::styled( - " • Enter to send, Alt+Enter (or Shift+Enter) for newline", + ]; + if let Some(composer_tip) = composer_tip { + lines.push(Line::from(Span::styled( + format!(" • {composer_tip}"), muted, - )), + ))); + } + lines.extend([ Line::from(Span::styled( " • / commands, @ to mention files, $ to invoke skills", muted, @@ -1330,12 +1484,44 @@ fn build_welcome_lines<'a>(state: &AppState, theme: &Theme) -> Vec> { " • /model to switch model, /compact to compress context", muted, )), - Line::from(Span::styled( - " • Ctrl+K or F1 for keyboard shortcuts", - muted, - )), + Line::from(Span::styled(format!(" • {shortcuts_tip}"), muted)), Line::from(""), - ] + ]); + lines +} + +fn welcome_composer_tip(keymap: &crate::keybindings::Keymap) -> Option { + let submit = crate::shortcuts::ShortcutAction::Idle(crate::shortcuts::IdleShortcut::Submit); + let newline = crate::shortcuts::ShortcutAction::Idle(crate::shortcuts::IdleShortcut::Newline); + if keymap.action_uses_built_ins(submit) && keymap.action_uses_built_ins(newline) { + return Some("Enter to send, Alt+Enter (or Shift+Enter) for newline".to_string()); + } + let submit_keys = keymap.action_keys(submit); + let newline_keys = keymap.action_keys(newline); + match (submit_keys.is_empty(), newline_keys.is_empty()) { + (true, true) => None, + (false, true) => Some(format!("{} to send", submit_keys.join(" / "))), + (true, false) => Some(format!("{} for newline", newline_keys.join(" / "))), + (false, false) => Some(format!( + "{} to send, {} for newline", + submit_keys.join(" / "), + newline_keys.join(" / "), + )), + } +} + +fn welcome_shortcuts_tip(keymap: &crate::keybindings::Keymap) -> String { + let action = + crate::shortcuts::ShortcutAction::Global(crate::shortcuts::GlobalShortcut::ToggleShortcuts); + if keymap.action_uses_built_ins(action) { + return "Ctrl+K or F1 for keyboard shortcuts".to_string(); + } + let keys = keymap.action_keys(action); + if keys.is_empty() { + "Keyboard shortcuts".to_string() + } else { + format!("{} for keyboard shortcuts", keys.join(" / ")) + } } /// Render the lines for a contiguous slice of messages. Used both to flush a settled @@ -1347,6 +1533,7 @@ fn build_welcome_lines<'a>(state: &AppState, theme: &Theme) -> Vec> { /// immutable scrollback in full — once flushed it can never be re-expanded, so we must /// not freeze a truncated view. The live pane passes `false` and honours the per-message /// `expanded` flag that `e` toggles. +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn build_lines_for_messages( messages: &[ChatMessage], theme: &Theme, @@ -1356,11 +1543,39 @@ pub(crate) fn build_lines_for_messages( ) -> Vec> { let mut lines: Vec> = Vec::new(); for msg in messages { - append_message_lines(&mut lines, msg, theme, width, tick, force_expand); + lines.extend(build_lines_for_message( + msg, + theme, + width, + tick, + force_expand, + None, + )); } lines } +pub(crate) fn build_lines_for_message( + message: &ChatMessage, + theme: &Theme, + width: usize, + tick: u64, + force_expand: bool, + refined_diff: Option<&crate::diff_highlight::RefinedDiffStyles>, +) -> Vec> { + let mut lines = Vec::new(); + append_message_lines( + &mut lines, + message, + theme, + width, + tick, + force_expand, + refined_diff, + ); + lines +} + /// Append the rendered lines for a single chat message. Pure with respect to global /// state: the only dynamic input is `tick`, which drives the running-tool spinner. fn append_message_lines( @@ -1370,6 +1585,7 @@ fn append_message_lines( width: usize, tick: u64, force_expand: bool, + refined_diff: Option<&crate::diff_highlight::RefinedDiffStyles>, ) { match msg { ChatMessage::User(text) => { @@ -1399,11 +1615,13 @@ fn append_message_lines( ])); } ChatMessage::Assistant(text) => { - let md_lines = render_markdown(text, width); - for line in md_lines { - lines.push(line); - } - lines.push(Line::from("")); + append_assistant_markdown(lines, text, width, theme, true); + } + ChatMessage::AssistantChunk { + text, + trailing_blank, + } => { + append_assistant_markdown(lines, text, width, theme, *trailing_blank); } ChatMessage::ProposedPlan(text) => { append_proposed_plan_lines(lines, text, width, theme); @@ -1460,7 +1678,7 @@ fn append_message_lines( append_tool_output_lines(lines, out, *expanded, force_expand, theme); } if let Some(diff) = diff { - append_diff_lines(lines, diff, theme); + append_diff_lines(lines, diff, theme, refined_diff); } } ChatMessage::PlanUpdate { explanation, plan } => { @@ -1510,6 +1728,19 @@ fn append_message_lines( } } +fn append_assistant_markdown( + lines: &mut Vec>, + text: &str, + width: usize, + theme: &Theme, + trailing_blank: bool, +) { + lines.extend(render_markdown(text, width, theme)); + if trailing_blank { + lines.push(Line::from("")); + } +} + fn plan_panel_height(state: &AppState) -> u16 { match &state.current_plan { Some((_, plan)) => { @@ -1634,11 +1865,10 @@ fn append_proposed_plan_lines( .fg(theme.approval) .add_modifier(Modifier::BOLD), )])); - for line in render_markdown(text, width.saturating_sub(2)) { - lines.push(Line::from(vec![ - Span::styled(" ", Style::default().fg(theme.muted)), - Span::styled(line.to_string(), Style::default().fg(theme.text)), - ])); + for mut line in render_markdown(text, width.saturating_sub(2), theme) { + line.spans + .insert(0, Span::styled(" ", Style::default().fg(theme.muted))); + lines.push(line); } lines.push(Line::from("")); } @@ -1727,29 +1957,15 @@ fn append_subagent_lines( ))); } -fn append_diff_lines(lines: &mut Vec>, diff: &str, theme: &Theme) { - let mut diff_lines = diff.lines(); - for line in diff_lines.by_ref().take(80) { - let color = if line.starts_with('+') && !line.starts_with("+++") { - theme.diff_add - } else if line.starts_with('-') && !line.starts_with("---") { - theme.diff_remove - } else if line.starts_with("@@") { - theme.border - } else { - theme.muted - }; - lines.push(Line::from(Span::styled( - format!(" {line}"), - Style::default().fg(color), - ))); - } - if diff_lines.next().is_some() { - lines.push(Line::from(Span::styled( - " [... diff truncated ...]", - Style::default().fg(theme.muted), - ))); - } +fn append_diff_lines( + lines: &mut Vec>, + diff: &str, + theme: &Theme, + refined: Option<&crate::diff_highlight::RefinedDiffStyles>, +) { + lines.extend(crate::diff_highlight::render_unified_diff( + diff, theme, refined, + )); } fn append_tool_output_lines( @@ -1819,9 +2035,149 @@ fn render_input( frame: &mut Frame, area: Rect, textarea: &TextArea, + layout: &TextareaVisualLayout, state: &AppState, theme: &Theme, -) { + show_hardware_cursor: bool, +) -> Option { + render_textarea_surface( + frame, + area, + textarea, + Some(layout), + state.copy_notice_at(std::time::Instant::now()), + theme, + show_hardware_cursor, + ) +} + +fn render_search_bar( + frame: &mut Frame, + area: Rect, + state: &AppState, + theme: &Theme, +) -> Option { + if area.is_empty() { + return None; + } + let count = format!( + " {}/{} ", + state.transcript_search.active_ordinal().unwrap_or(0), + state.transcript_search.match_count() + ); + let count_width = UnicodeWidthStr::width(count.as_str()).min(area.width as usize) as u16; + let prefix = if area.width.saturating_sub(count_width) >= 7 { + " Find: " + } else { + "F:" + }; + let prefix_width = UnicodeWidthStr::width(prefix).min(area.width as usize) as u16; + let query_width = area.width.saturating_sub(prefix_width + count_width); + + if prefix_width > 0 { + frame.render_widget( + Paragraph::new(Span::styled(prefix, Style::default().fg(theme.muted))), + Rect::new(area.x, area.y, prefix_width, 1), + ); + } + if count_width > 0 { + frame.render_widget( + Paragraph::new(Span::styled(count, Style::default().fg(theme.muted))), + Rect::new(area.right() - count_width, area.y, count_width, 1), + ); + } + if query_width == 0 { + return None; + } + + let mut textarea = TextArea::from([state.transcript_search.query()]); + textarea.set_cursor_line_style(Style::default()); + textarea.set_cursor_style( + Style::default() + .fg(theme.border) + .add_modifier(Modifier::REVERSED), + ); + let cursor_column = state.transcript_search.query()[..state.transcript_search.cursor()] + .chars() + .count() + .min(u16::MAX as usize) as u16; + textarea.move_cursor(tui_textarea::CursorMove::Jump(0, cursor_column)); + render_textarea_surface( + frame, + Rect::new(area.x + prefix_width, area.y, query_width, 1), + &textarea, + None, + None, + theme, + !state.show_shortcuts && state.status != AppStatus::WaitingApproval, + ) +} + +fn render_textarea_surface( + frame: &mut Frame, + area: Rect, + textarea: &TextArea, + precomputed_layout: Option<&TextareaVisualLayout>, + notice: Option, + theme: &Theme, + show_hardware_cursor: bool, +) -> Option { + let inner = render_textarea_block_and_notice(frame, area, textarea, notice, theme); + render_textarea_content( + frame, + inner, + textarea, + precomputed_layout, + theme, + show_hardware_cursor, + ) +} + +fn render_textarea_content( + frame: &mut Frame, + inner: Rect, + textarea: &TextArea, + precomputed_layout: Option<&TextareaVisualLayout>, + theme: &Theme, + show_hardware_cursor: bool, +) -> Option { + if inner.is_empty() { + return None; + } + + let computed_layout; + let layout = if let Some(layout) = precomputed_layout { + layout + } else { + computed_layout = textarea_visual_layout_with_selection( + textarea, + inner.width as usize, + theme.selection_style(), + ); + &computed_layout + }; + let visible_height = inner.height as usize; + let start = textarea_visible_start(layout, visible_height); + let end = (start + visible_height).min(layout.lines.len()); + let visible = layout.lines[start..end].to_vec(); + let paragraph = Paragraph::new(visible) + .style(textarea.style()) + .alignment(layout.alignment); + frame.render_widget(paragraph, inner); + + show_hardware_cursor + .then(|| visible_textarea_cursor(layout, inner)) + .flatten() + .map(|position| HardwareCursorProjection { position }) +} + +fn render_textarea_block_and_notice( + frame: &mut Frame, + area: Rect, + textarea: &TextArea, + notice: Option, + theme: &Theme, +) -> Rect { let inner = if let Some(block) = textarea.block() { let inner = block.inner(area); frame.render_widget(block, area); @@ -1832,7 +2188,7 @@ fn render_input( // Transient "copied N chars" feedback overlays the right end of the top // border, mirroring the " Input " title on the left. - if let Some(notice) = state.copy_notice_at(std::time::Instant::now()) { + if let Some(notice) = notice { let text = if notice.local_only { format!(" copied {} chars (local clipboard only) ", notice.chars) } else { @@ -1849,29 +2205,15 @@ fn render_input( } } - if inner.is_empty() { - return; - } - - let (lines, cursor_line) = composer_visual_lines(textarea, inner.width as usize); - let visible_height = inner.height as usize; - let start = if lines.len() <= visible_height { - 0 - } else if cursor_line >= visible_height { - cursor_line + 1 - visible_height - } else { - 0 - }; - let end = (start + visible_height).min(lines.len()); - let visible = lines[start..end].to_vec(); - let paragraph = Paragraph::new(visible) - .style(textarea.style()) - .alignment(textarea.alignment()); - frame.render_widget(paragraph, inner); + inner } -fn composer_input_height(area_width: u16, textarea: &TextArea) -> u16 { - let input_lines = composer_visual_line_count(area_width, textarea).max(1) as u16; +fn composer_input_height( + area_width: u16, + textarea: &TextArea, + layout: &TextareaVisualLayout, +) -> u16 { + let input_lines = layout.lines.len().max(1) as u16; let block_extra = textarea .block() .map(|block| { @@ -1882,17 +2224,13 @@ fn composer_input_height(area_width: u16, textarea: &TextArea) -> u16 { input_lines.saturating_add(block_extra) } -fn composer_visual_line_count(area_width: u16, textarea: &TextArea) -> usize { +fn composer_visual_layout( + area_width: u16, + textarea: &TextArea, + theme: &Theme, +) -> TextareaVisualLayout { let inner_width = textarea_inner_width(area_width, textarea) as usize; - if textarea.is_empty() { - return 1; - } - textarea - .lines() - .iter() - .map(|line| textarea_wrap_ranges(line, inner_width).len()) - .sum::() - .max(1) + textarea_visual_layout_with_selection(textarea, inner_width, theme.selection_style()) } fn textarea_inner_width(area_width: u16, textarea: &TextArea) -> u16 { @@ -1902,91 +2240,417 @@ fn textarea_inner_width(area_width: u16, textarea: &TextArea) -> u16 { .unwrap_or(area_width) } -fn composer_visual_lines(textarea: &TextArea, width: usize) -> (Vec>, usize) { +struct TextareaVisualLayout { + lines: Vec>, + cursor_visual_row: usize, + #[cfg_attr(not(test), allow(dead_code))] + cursor_display_col: usize, + cursor_cell_width: usize, + alignment: Alignment, + rows: Vec, +} + +struct TextareaVisualRow { + logical_row: usize, + logical_range: Range, + graphemes: Vec, +} + +struct TextareaHitGrapheme { + logical_range: Range, + width: usize, +} + +#[derive(Clone, Copy)] +enum TextareaCursorCell { + Occupied { display_col: usize, width: usize }, + Space, +} + +impl TextareaCursorCell { + fn display_col(self, raw_display_col: usize) -> usize { + match self { + Self::Occupied { display_col, .. } => display_col, + Self::Space => raw_display_col, + } + } + + fn width(self) -> usize { + match self { + Self::Occupied { width, .. } => width, + Self::Space => 1, + } + } +} + +fn textarea_visual_layout(textarea: &TextArea, width: usize) -> TextareaVisualLayout { + textarea_visual_layout_with_selection(textarea, width, Style::default().bg(Color::LightBlue)) +} + +fn textarea_visual_layout_with_selection( + textarea: &TextArea, + width: usize, + selection_style: Style, +) -> TextareaVisualLayout { if textarea.is_empty() { let mut spans = vec![Span::styled(" ", textarea.cursor_style())]; if let Some(style) = textarea.placeholder_style() { spans.push(Span::styled(textarea.placeholder_text().to_string(), style)); } - return (vec![Line::from(spans)], 0); + return TextareaVisualLayout { + lines: vec![Line::from(spans)], + cursor_visual_row: 0, + cursor_display_col: 0, + cursor_cell_width: 1, + alignment: textarea.alignment(), + rows: vec![TextareaVisualRow { + logical_row: 0, + logical_range: 0..0, + graphemes: Vec::new(), + }], + }; } let (cursor_row, cursor_col) = textarea.cursor(); let selection = textarea.selection_range(); let mut visual_lines = Vec::new(); + let mut visual_rows = Vec::new(); let mut cursor_visual_line = 0usize; - - for (row, logical_line) in textarea.lines().iter().enumerate() { - let ranges = textarea_wrap_ranges(logical_line, width); - for range in ranges { + let mut cursor_display_col = 0usize; + let mut cursor_cell_width = 0usize; + + for (row, original_line) in textarea.lines().iter().enumerate() { + let display_line = textarea_display_line(textarea, original_line); + let graphemes = textarea_graphemes(&display_line); + let ranges = textarea_wrap_ranges(&graphemes, display_line.chars().count(), width); + for (range_index, range) in ranges.iter().enumerate() { + let range_graphemes = textarea_graphemes_in_range(&graphemes, range); let visual_index = visual_lines.len(); - if row == cursor_row && cursor_in_visual_range(cursor_col, &range, logical_line) { - cursor_visual_line = visual_index; - } + let is_last_range = range_index + 1 == ranges.len(); + let contains_cursor = row == cursor_row + && cursor_in_visual_range( + cursor_col, + range, + is_last_range, + original_line.chars().count(), + ); + let raw_cursor_display_col = contains_cursor + .then(|| textarea_display_width(&display_line, range.start, cursor_col)); + let cursor_inside_grapheme = contains_cursor + && range_graphemes.iter().any(|grapheme| { + grapheme.logical_range.start < cursor_col + && cursor_col < grapheme.logical_range.end + }); + let needs_synthetic_cursor = raw_cursor_display_col + .is_some_and(|display_col| width > 0 && display_col >= width) + && !cursor_inside_grapheme; + let cursor_cell = raw_cursor_display_col.and_then(|raw_display_col| { + if needs_synthetic_cursor || width == 0 { + None + } else { + let cursor_cell = + textarea_cursor_cell(range_graphemes, cursor_col, raw_display_col); + cursor_visual_line = visual_index; + cursor_display_col = cursor_cell.display_col(raw_display_col); + cursor_cell_width = cursor_cell.width(); + Some(cursor_cell) + } + }); visual_lines.push(render_textarea_visual_line( - logical_line, + original_line, row, - range, + range.clone(), textarea, selection, + cursor_cell, + range_graphemes, + selection_style, )); + visual_rows.push(TextareaVisualRow { + logical_row: row, + logical_range: range.clone(), + graphemes: range_graphemes + .iter() + .map(|grapheme| TextareaHitGrapheme { + logical_range: grapheme.logical_range.clone(), + width: grapheme.width, + }) + .collect(), + }); + if needs_synthetic_cursor { + cursor_visual_line = visual_lines.len(); + cursor_display_col = 0; + cursor_cell_width = 1; + visual_lines.push(Line::from(Span::styled(" ", textarea.cursor_style()))); + visual_rows.push(TextareaVisualRow { + logical_row: row, + logical_range: cursor_col..cursor_col, + graphemes: Vec::new(), + }); + } } } if visual_lines.is_empty() { visual_lines.push(Line::from(Span::styled(" ", textarea.cursor_style()))); + visual_rows.push(TextareaVisualRow { + logical_row: 0, + logical_range: 0..0, + graphemes: Vec::new(), + }); } - (visual_lines, cursor_visual_line) + TextareaVisualLayout { + lines: visual_lines, + cursor_visual_row: cursor_visual_line, + cursor_display_col, + cursor_cell_width, + alignment: textarea.alignment(), + rows: visual_rows, + } } -fn cursor_in_visual_range(cursor_col: usize, range: &Range, logical_line: &str) -> bool { - let line_len = logical_line.chars().count(); - (range.start <= cursor_col && cursor_col < range.end) - || (cursor_col == line_len && range.end == line_len) - || (range.is_empty() && cursor_col == range.start) +fn textarea_display_line(textarea: &TextArea, logical_line: &str) -> String { + match textarea.mask_char() { + Some(mask) => std::iter::repeat(mask) + .take(logical_line.chars().count()) + .collect(), + None => logical_line.to_string(), + } } -fn render_textarea_visual_line( - logical_line: &str, - row: usize, - range: Range, - textarea: &TextArea, - selection: Option<((usize, usize), (usize, usize))>, -) -> Line<'static> { - let (cursor_row, cursor_col) = textarea.cursor(); - let base_style = textarea.style(); - let cursor_style = textarea.cursor_style(); +struct TextareaGrapheme<'a> { + text: &'a str, + logical_range: Range, + width: usize, +} + +#[cfg(test)] +thread_local! { + static TEXTAREA_GRAPHEME_TOKENIZATIONS: std::cell::Cell = + const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn reset_textarea_grapheme_tokenization_count() { + TEXTAREA_GRAPHEME_TOKENIZATIONS.set(0); +} + +#[cfg(test)] +fn textarea_grapheme_tokenization_count() -> usize { + TEXTAREA_GRAPHEME_TOKENIZATIONS.get() +} + +fn textarea_graphemes(line: &str) -> Vec> { + #[cfg(test)] + TEXTAREA_GRAPHEME_TOKENIZATIONS.with(|count| count.set(count.get() + 1)); + let mut logical_start = 0usize; + line.graphemes(true) + .map(|text| { + let logical_end = logical_start + text.chars().count(); + let grapheme = TextareaGrapheme { + text, + logical_range: logical_start..logical_end, + width: UnicodeWidthStr::width(text), + }; + logical_start = logical_end; + grapheme + }) + .collect() +} + +fn textarea_graphemes_in_range<'a>( + graphemes: &'a [TextareaGrapheme<'a>], + range: &Range, +) -> &'a [TextareaGrapheme<'a>] { + let start = graphemes.partition_point(|grapheme| grapheme.logical_range.end <= range.start); + let end = graphemes.partition_point(|grapheme| grapheme.logical_range.start < range.end); + &graphemes[start.min(end)..end] +} + +fn cursor_in_visual_range( + cursor_col: usize, + range: &Range, + is_last_range: bool, + line_len: usize, +) -> bool { + range.contains(&cursor_col) || (is_last_range && cursor_col == line_len) +} + +fn textarea_display_width(display_line: &str, start: usize, end: usize) -> usize { + UnicodeWidthStr::width(textarea_char_slice(display_line, start, end)) +} + +fn textarea_char_slice(text: &str, start: usize, end: usize) -> &str { + let start = textarea_char_byte_index(text, start); + let end = textarea_char_byte_index(text, end); + &text[start.min(end)..end] +} + +fn textarea_char_byte_index(text: &str, char_index: usize) -> usize { + text.char_indices() + .nth(char_index) + .map_or(text.len(), |(byte_index, _)| byte_index) +} + +fn textarea_cursor_cell( + graphemes: &[TextareaGrapheme<'_>], + cursor_col: usize, + raw_display_col: usize, +) -> TextareaCursorCell { + let mut display_col = 0usize; + let mut cursor_in_zero_width_grapheme = false; + for grapheme in graphemes { + let next_display_col = display_col + grapheme.width; + if grapheme.width == 0 { + cursor_in_zero_width_grapheme |= grapheme.logical_range.contains(&cursor_col); + } else if grapheme.logical_range.contains(&cursor_col) + || cursor_in_zero_width_grapheme + || raw_display_col < next_display_col + { + return TextareaCursorCell::Occupied { + display_col, + width: grapheme.width, + }; + } + display_col = next_display_col; + } + TextareaCursorCell::Space +} + +fn textarea_visible_start(layout: &TextareaVisualLayout, visible_height: usize) -> usize { + if layout.lines.len() <= visible_height { + 0 + } else if layout.cursor_visual_row >= visible_height { + layout.cursor_visual_row + 1 - visible_height + } else { + 0 + } +} + +#[cfg_attr(not(test), allow(dead_code))] +fn visible_textarea_cursor(layout: &TextareaVisualLayout, inner: Rect) -> Option { + if inner.is_empty() + || layout.alignment != Alignment::Left + || layout.cursor_visual_row >= layout.lines.len() + || layout + .cursor_display_col + .checked_add(layout.cursor_cell_width)? + > inner.width as usize + { + return None; + } + let start = textarea_visible_start(layout, inner.height as usize); + let row = layout.cursor_visual_row.checked_sub(start)?; + if row >= inner.height as usize { + return None; + } + Some(Position::new( + inner + .x + .checked_add(layout.cursor_display_col.try_into().ok()?)?, + inner.y.checked_add(row.try_into().ok()?)?, + )) +} + +fn render_textarea_visual_line( + original_line: &str, + row: usize, + range: Range, + textarea: &TextArea, + selection: Option<((usize, usize), (usize, usize))>, + cursor_cell: Option, + graphemes: &[TextareaGrapheme<'_>], + selection_style: Style, +) -> Line<'static> { + let base_style = textarea.style(); + let cursor_style = textarea.cursor_style(); let cursor_line_style = textarea.cursor_line_style(); - let selection_style = Style::default().bg(Color::LightBlue); let mut spans = Vec::new(); let mut pending = String::new(); let mut pending_style = base_style; - - for (col, ch) in logical_line - .chars() - .enumerate() - .skip(range.start) - .take(range.end.saturating_sub(range.start)) - { - let style = if row == cursor_row && col == cursor_col { + let mut attached_zero_width = String::new(); + let mut attached_logical_start = None; + let mut rendered_visible_grapheme = false; + let mut display_col = 0usize; + + for grapheme in graphemes { + if grapheme.width == 0 { + attached_logical_start.get_or_insert(grapheme.logical_range.start); + attached_zero_width.push_str(grapheme.text); + continue; + } + let logical_range = attached_logical_start + .take() + .unwrap_or(grapheme.logical_range.start) + ..grapheme.logical_range.end; + let next_display_col = display_col + grapheme.width; + let style = if matches!( + cursor_cell, + Some(TextareaCursorCell::Occupied { + display_col: cursor_col, + .. + }) if display_col == cursor_col + ) { cursor_style - } else if selection_contains(selection, row, col) { + } else if logical_range + .clone() + .any(|col| selection_contains(selection, row, col)) + { selection_style - } else if row == cursor_row { + } else if row == textarea.cursor().0 { cursor_line_style } else { base_style }; - push_styled_char(&mut spans, &mut pending, &mut pending_style, ch, style); + let mut rendered_grapheme = std::mem::take(&mut attached_zero_width); + rendered_grapheme.push_str(grapheme.text); + attached_zero_width.clear(); + push_styled_text( + &mut spans, + &mut pending, + &mut pending_style, + &rendered_grapheme, + style, + ); + rendered_visible_grapheme = true; + display_col = next_display_col; } + let zero_width_cursor_cell = !attached_zero_width.is_empty() && !rendered_visible_grapheme; + if zero_width_cursor_cell { + let logical_start = attached_logical_start.unwrap_or(range.end); + let logical_range = logical_start..range.end; + let style = if cursor_cell.is_some() { + cursor_style + } else if logical_range + .clone() + .any(|col| selection_contains(selection, row, col)) + { + selection_style + } else if row == textarea.cursor().0 { + cursor_line_style + } else { + base_style + }; + let mut cursor_cell = String::from(" "); + cursor_cell.push_str(&attached_zero_width); + push_styled_text( + &mut spans, + &mut pending, + &mut pending_style, + &cursor_cell, + style, + ); + } else if !attached_zero_width.is_empty() { + pending.push_str(&attached_zero_width); + } flush_pending_span(&mut spans, &mut pending, pending_style); - if row == cursor_row && cursor_col == range.end && cursor_col == logical_line.chars().count() { + if matches!(cursor_cell, Some(TextareaCursorCell::Space)) && !zero_width_cursor_cell { spans.push(Span::styled(" ", cursor_style)); } else if selection_contains(selection, row, range.end) - && range.end == logical_line.chars().count() + && range.end == original_line.chars().count() { spans.push(Span::styled(" ", selection_style)); } @@ -2006,11 +2670,11 @@ fn selection_contains( && (row < end_row || (row == end_row && col < end_col)) } -fn push_styled_char( +fn push_styled_text( spans: &mut Vec>, pending: &mut String, pending_style: &mut Style, - ch: char, + text: &str, style: Style, ) { if pending.is_empty() { @@ -2019,7 +2683,7 @@ fn push_styled_char( flush_pending_span(spans, pending, *pending_style); *pending_style = style; } - pending.push(ch); + pending.push_str(text); } fn flush_pending_span(spans: &mut Vec>, pending: &mut String, pending_style: Style) { @@ -2028,76 +2692,96 @@ fn flush_pending_span(spans: &mut Vec>, pending: &mut String, pend } } -fn textarea_wrap_ranges(line: &str, width: usize) -> Vec> { - if line.is_empty() || width == 0 { - return vec![0..line.chars().count()]; +fn textarea_wrap_ranges( + graphemes: &[TextareaGrapheme<'_>], + line_len: usize, + width: usize, +) -> Vec> { + if graphemes.is_empty() || width == 0 { + return vec![0..line_len]; } let mut ranges = Vec::new(); - let mut current_start = 0usize; + let mut current_start = None; let mut current_end = 0usize; let mut current_width = 0usize; - let mut segment_start = 0usize; + let mut segment_start = 0; - for segment in line.split_inclusive(|c: char| c.is_whitespace() || c == '/' || c == '-') { - let segment_cols = segment.chars().count(); - let segment_width = UnicodeWidthStr::width(segment); - if segment_width == 0 { - segment_start += segment_cols; + for segment_end in 1..=graphemes.len() { + let final_grapheme = &graphemes[segment_end - 1]; + if segment_end < graphemes.len() && !textarea_grapheme_ends_segment(final_grapheme) { continue; } + let segment = &graphemes[segment_start..segment_end]; + let segment_logical_start = segment[0].logical_range.start; + let segment_logical_end = segment + .last() + .map(|grapheme| grapheme.logical_range.end) + .unwrap_or(segment_logical_start); + let segment_width = segment.iter().map(|grapheme| grapheme.width).sum::(); if segment_width > width { - if current_width > 0 { - ranges.push(current_start..current_end); + if let Some(start) = current_start.take() { + ranges.push(start..current_end); } let (start, end, display_width) = - push_hard_wrapped_segment(&mut ranges, segment, segment_start, width); - current_start = start; + push_hard_wrapped_segment(&mut ranges, segment, width); + current_start = Some(start); current_end = end; current_width = display_width; - } else if current_width == 0 { - current_start = segment_start; - current_end = segment_start + segment_cols; + } else if current_start.is_none() { + current_start = Some(segment_logical_start); + current_end = segment_logical_end; current_width = segment_width; } else if current_width + segment_width <= width { - current_end = segment_start + segment_cols; + current_end = segment_logical_end; current_width += segment_width; } else { - ranges.push(current_start..current_end); - current_start = segment_start; - current_end = segment_start + segment_cols; + ranges.push(current_start.unwrap_or(segment_logical_start)..current_end); + current_start = Some(segment_logical_start); + current_end = segment_logical_end; current_width = segment_width; } - segment_start += segment_cols; + segment_start = segment_end; } - if current_width > 0 || ranges.is_empty() { - ranges.push(current_start..current_end); + if let Some(start) = current_start { + ranges.push(start..current_end); + } else if ranges.is_empty() { + ranges.push(0..line_len); } ranges } +fn textarea_grapheme_ends_segment(grapheme: &TextareaGrapheme<'_>) -> bool { + grapheme + .text + .chars() + .next() + .is_some_and(|ch| ch.is_whitespace() || ch == '/' || ch == '-') +} + fn push_hard_wrapped_segment( ranges: &mut Vec>, - segment: &str, - segment_start: usize, + segment: &[TextareaGrapheme<'_>], width: usize, ) -> (usize, usize, usize) { - let mut chunk_start = segment_start; - let mut current_col = segment_start; + let mut chunk_start = segment + .first() + .map(|grapheme| grapheme.logical_range.start) + .unwrap_or(0); + let mut current_col = chunk_start; let mut current_width = 0usize; - for ch in segment.chars() { - let ch_width = UnicodeWidthStr::width(ch.to_string().as_str()).max(1); - if current_width > 0 && current_width + ch_width > width { + for grapheme in segment { + if current_width > 0 && grapheme.width > 0 && current_width + grapheme.width > width { ranges.push(chunk_start..current_col); - chunk_start = current_col; + chunk_start = grapheme.logical_range.start; current_width = 0; } - current_col += 1; - current_width += ch_width; + current_col = grapheme.logical_range.end; + current_width += grapheme.width; } (chunk_start, current_col, current_width) @@ -2112,17 +2796,81 @@ fn render_status(frame: &mut Frame, area: Rect, state: &AppState, theme: &Theme) frame.render_widget(paragraph, area); } +fn workspace_status_spans( + state: &AppState, + theme: &Theme, + available_width: usize, +) -> Vec> { + let separator = " · "; + let separator_width = UnicodeWidthStr::width(separator); + if available_width <= separator_width { + return Vec::new(); + } + + let git_label = state.workspace_git.as_ref().map(GitIdentity::label); + let git_width = git_label + .as_deref() + .map(|label| separator_width + UnicodeWidthStr::width(label)) + .unwrap_or(0); + + let make_spans = |cwd: String, git: Option| { + let mut spans = vec![Span::styled( + format!("{separator}{cwd}"), + Style::default().fg(theme.muted), + )]; + if let Some(git) = git { + spans.push(Span::styled( + format!("{separator}{git}"), + Style::default().fg(theme.muted), + )); + } + spans + }; + + if let Some(git) = git_label.as_ref() + && available_width > separator_width + git_width + { + let cwd = compact_cwd( + &state.cwd, + available_width + .saturating_sub(separator_width) + .saturating_sub(git_width), + ); + if !cwd.is_empty() { + return make_spans(cwd, Some(git.clone())); + } + } + + let cwd = compact_cwd(&state.cwd, available_width - separator_width); + if cwd.is_empty() { + Vec::new() + } else { + make_spans(cwd, None) + } +} + fn status_line(state: &AppState, theme: &Theme, width: usize) -> Line<'static> { let separator = " · "; let mode_prefix = separator; let mode_value = state.approval_mode.as_str(); let mode_width = UnicodeWidthStr::width(mode_prefix) + UnicodeWidthStr::width(mode_value); + let context = (state.context_limit_tokens > 0).then(|| context_cell(state, theme)); + let reserved_context_width = context + .as_ref() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .filter(|context_width| mode_width + context_width <= width) + .unwrap_or(0); let model = format!( " {} ({})", state.model_name, state.reasoning_effort.as_str() ); - let model = truncate_to_display_width(&model, width.saturating_sub(mode_width)); + let model = truncate_to_display_width( + &model, + width + .saturating_sub(mode_width) + .saturating_sub(reserved_context_width), + ); let mut used = UnicodeWidthStr::width(model.as_str()) + mode_width; let mut spans = vec![ Span::styled(model, Style::default().fg(theme.muted)), @@ -2133,14 +2881,24 @@ fn status_line(state: &AppState, theme: &Theme, width: usize) -> Line<'static> { ), ]; - let mut optional = Vec::new(); - if state.context_limit_tokens > 0 { - optional.push(context_cell(state, theme)); + if let Some(context) = context { + let context_width = UnicodeWidthStr::width(context.content.as_ref()); + if used + context_width <= width { + used += context_width; + spans.push(context); + } + } + + for span in workspace_status_spans(state, theme, width.saturating_sub(used)) { + used += UnicodeWidthStr::width(span.content.as_ref()); + spans.push(span); } + + let mut lower_priority = Vec::new(); // Session cost only appears once there is something to report; a fresh // session keeps the bar clean instead of showing zeros. if state.usage.total_tokens() > 0 { - optional.push(Span::styled( + lower_priority.push(Span::styled( format!( "{separator}{} tokens{separator}{}", format_token_count(state.usage.total_tokens()), @@ -2149,12 +2907,12 @@ fn status_line(state: &AppState, theme: &Theme, width: usize) -> Line<'static> { Style::default().fg(theme.muted), )); } - optional.push(Span::styled( - format!("{separator}F1 shortcuts"), + lower_priority.push(Span::styled( + format!("{separator}{}", status_shortcuts_hint(&state.keymap)), Style::default().fg(theme.muted), )); - for span in optional { + for span in lower_priority { let span_width = UnicodeWidthStr::width(span.content.as_ref()); if used + span_width <= width { used += span_width; @@ -2165,6 +2923,20 @@ fn status_line(state: &AppState, theme: &Theme, width: usize) -> Line<'static> { Line::from(spans) } +fn status_shortcuts_hint(keymap: &crate::keybindings::Keymap) -> String { + let action = + crate::shortcuts::ShortcutAction::Global(crate::shortcuts::GlobalShortcut::ToggleShortcuts); + if keymap.action_uses_built_ins(action) { + return "F1 shortcuts".to_string(); + } + let keys = keymap.action_keys(action); + if keys.is_empty() { + "shortcuts".to_string() + } else { + format!("{} shortcuts", keys.join(" / ")) + } +} + /// Humanize token counts for the status bar: 950 → "950", 8_664 → "8.7k", /// 1_250_000 → "1.3M". Full precision lives in `/cost`. fn format_token_count(tokens: u64) -> String { @@ -2280,7 +3052,7 @@ fn render_shortcuts(frame: &mut Frame, state: &AppState, theme: &Theme) { let width = 58u16.min(area.width.saturating_sub(4)); let max_height = area.height.saturating_sub(4); let scopes = active_shortcut_scopes(state); - let lines = shortcuts::shortcut_lines(&scopes); + let lines = shortcuts::shortcut_lines_with_keymap(&scopes, &state.keymap); let height = ((lines.len() as u16) + 2).min(max_height).max(3); let popup_area = centered_rect(area, width, height); @@ -2310,12 +3082,11 @@ fn active_shortcut_scopes(state: &AppState) -> Vec { } } -/// The scrolled 12-item window both popup menus (slash, mention) use: the +/// The scrolled item window both popup menus (slash, mention) use: the /// selected row stays visible, pinned to the window's bottom while moving -/// down. Shared by the renderers and the mouse hit-tests so they can never -/// disagree. -fn popup_window(len: usize, selected: usize) -> (usize, usize) { - let visible_count = len.min(12); +/// down. +fn popup_window(len: usize, selected: usize, max_visible: usize) -> (usize, usize) { + let visible_count = len.min(12).min(max_visible); let max_start = len.saturating_sub(visible_count); let start = selected .saturating_sub(visible_count.saturating_sub(1)) @@ -2323,26 +3094,61 @@ fn popup_window(len: usize, selected: usize) -> (usize, usize) { (start, (start + visible_count).min(len)) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PopupGeometry { + area: Rect, + start: usize, + end: usize, + show_status: bool, +} + +fn popup_geometry( + frame_area: Rect, + input_area: Rect, + len: usize, + selected: usize, + wants_status: bool, +) -> Option { + let left = input_area.x.max(frame_area.x); + let right = input_area.right().min(frame_area.right()); + let width = right.checked_sub(left)?; + let bottom = input_area.y.clamp(frame_area.y, frame_area.bottom()); + let available_height = bottom.saturating_sub(frame_area.y); + if width == 0 || available_height < 2 { + return None; + } + + let max_content_rows = available_height.saturating_sub(2) as usize; + let show_status = wants_status && max_content_rows > 0 && (len == 0 || max_content_rows >= 2); + let max_items = max_content_rows.saturating_sub(usize::from(show_status)); + let (start, end) = popup_window(len, selected, max_items); + let height = (end - start) + .saturating_add(usize::from(show_status)) + .saturating_add(2) + .min(available_height as usize) as u16; + + Some(PopupGeometry { + area: Rect::new(left, bottom - height, width, height), + start, + end, + show_status, + }) +} + /// Which slash-menu item (of the active list — sub-menu when open) a click -/// lands on, replicating `render_slash_menu` geometry. +/// lands on. pub(crate) fn slash_menu_hit_index(state: &AppState, column: u16, row: u16) -> Option { let menu = state.slash_menu.as_ref()?; + let frame_area = state.frame_area?; let input_area = state.input_area?; let (len, selected) = match &menu.sub_menu { Some(sub) => (sub.items.len(), sub.selected), None => (menu.items.len(), menu.selected), }; - let (start, end) = popup_window(len, selected); - let height = ((end - start) as u16 + 2).min(14); - let popup = Rect::new( - input_area.x, - input_area.y.saturating_sub(height), - input_area.width, - height, - ); - hit_bordered_list_row(popup, column, row).and_then(|offset| { - let index = start + offset; - (index < end).then_some(index) + let geometry = popup_geometry(frame_area, input_area, len, selected, false)?; + hit_bordered_list_row(geometry.area, column, row).and_then(|offset| { + let index = geometry.start + offset; + (index < geometry.end).then_some(index) }) } @@ -2356,24 +3162,24 @@ pub(crate) fn mention_menu_hit_index(state: &AppState, column: u16, row: u16) -> return None; } let phase = state.mention.phase.as_ref()?; + let frame_area = state.frame_area?; let input_area = state.input_area?; let candidates = &state.mention.candidates; - let (start, end) = popup_window(candidates.len(), state.mention.selected); let status = mention_status_text( phase, state.mention.progress.scanned_paths, candidates.is_empty(), ); - let height = (end - start) as u16 + u16::from(status.is_some()) + 2; - let popup = Rect::new( - input_area.x, - input_area.y.saturating_sub(height), - input_area.width, - height, - ); - hit_bordered_list_row(popup, column, row).and_then(|offset| { - let index = start + offset; - (index < end).then_some(index) + let geometry = popup_geometry( + frame_area, + input_area, + candidates.len(), + state.mention.selected, + status.is_some(), + )?; + hit_bordered_list_row(geometry.area, column, row).and_then(|offset| { + let index = geometry.start + offset; + (index < geometry.end).then_some(index) }) } @@ -2411,19 +3217,16 @@ fn render_slash_menu(frame: &mut Frame, input_area: Rect, state: &AppState, them (items, menu.selected, " Commands ") }; - let (start, end) = popup_window(items.len(), selected); - let visible_count = end - start; - let item_count = visible_count as u16; - let height = (item_count + 2).min(14); // +2 for border - let width = input_area.width; - let y = input_area.y.saturating_sub(height); - let popup_area = Rect::new(input_area.x, y, width, height); + let Some(geometry) = popup_geometry(frame.area(), input_area, items.len(), selected, false) + else { + return; + }; - frame.render_widget(Clear, popup_area); + frame.render_widget(Clear, geometry.area); let mut lines: Vec = Vec::new(); - for (i, (cmd, desc)) in items[start..end].iter().enumerate() { - let item_index = start + i; + for (i, (cmd, desc)) in items[geometry.start..geometry.end].iter().enumerate() { + let item_index = geometry.start + i; let prefix = if item_index == selected { "▸ " } else { " " }; let style = if item_index == selected { Style::default() @@ -2450,7 +3253,7 @@ fn render_slash_menu(frame: &mut Frame, input_area: Rect, state: &AppState, them .border_style(Style::default().fg(theme.border)); let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, popup_area); + frame.render_widget(paragraph, geometry.area); } fn render_mention_candidates(frame: &mut Frame, input_area: Rect, state: &AppState, theme: &Theme) { @@ -2459,26 +3262,28 @@ fn render_mention_candidates(frame: &mut Frame, input_area: Rect, state: &AppSta return; }; - let (start, end) = popup_window(candidates.len(), state.mention.selected); - let visible_count = end - start; let status = mention_status_text( phase, state.mention.progress.scanned_paths, candidates.is_empty(), ); - let item_count = visible_count as u16 + u16::from(status.is_some()); - let height = item_count + 2; - let width = input_area.width; - let y = input_area.y.saturating_sub(height); - let popup_area = Rect::new(input_area.x, y, width, height); + let Some(geometry) = popup_geometry( + frame.area(), + input_area, + candidates.len(), + state.mention.selected, + status.is_some(), + ) else { + return; + }; - frame.render_widget(Clear, popup_area); + frame.render_widget(Clear, geometry.area); let mut lines: Vec = candidates .iter() .enumerate() - .skip(start) - .take(end.saturating_sub(start)) + .skip(geometry.start) + .take(geometry.end.saturating_sub(geometry.start)) .map(|(i, candidate)| { let prefix = if i == state.mention.selected { "▸ " @@ -2511,7 +3316,9 @@ fn render_mention_candidates(frame: &mut Frame, input_area: Rect, state: &AppSta Line::from(spans) }) .collect(); - if let Some((text, color)) = status { + if geometry.show_status + && let Some((text, color)) = status + { lines.push(Line::from(Span::styled( format!(" {text}"), Style::default().fg(color), @@ -2525,7 +3332,7 @@ fn render_mention_candidates(frame: &mut Frame, input_area: Rect, state: &AppSta .border_style(Style::default().fg(theme.border)); let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, popup_area); + frame.render_widget(paragraph, geometry.area); } fn mention_status_text( @@ -2782,50 +3589,45 @@ pub(crate) fn composer_click_target( } let width = inner.width as usize; - let (cursor_row, cursor_col) = textarea.cursor(); - let mut visual: Vec<(usize, Range)> = Vec::new(); - let mut cursor_visual_line = 0usize; - for (logical_row, logical_line) in textarea.lines().iter().enumerate() { - for range in textarea_wrap_ranges(logical_line, width) { - if logical_row == cursor_row && cursor_in_visual_range(cursor_col, &range, logical_line) - { - cursor_visual_line = visual.len(); - } - visual.push((logical_row, range)); - } - } - if visual.is_empty() { + let layout = textarea_visual_layout(textarea, width); + if layout.rows.is_empty() { return Some((0, 0)); } // Same scroll-to-cursor behavior as render_input. - let visible_height = inner.height as usize; - let start = if visual.len() <= visible_height { - 0 - } else if cursor_visual_line >= visible_height { - cursor_visual_line + 1 - visible_height - } else { - 0 - }; - let clicked = (start + (row - inner.y) as usize).min(visual.len() - 1); - let (logical_row, range) = visual[clicked].clone(); - let logical_line = textarea.lines()[logical_row].as_str(); + let start = textarea_visible_start(&layout, inner.height as usize); + let clicked = (start + (row - inner.y) as usize).min(layout.rows.len() - 1); + let target = (column - inner.x) as usize; + if clicked == layout.cursor_visual_row && target == layout.cursor_display_col { + let (cursor_row, cursor_col) = textarea.cursor(); + return Some(( + cursor_row.min(u16::MAX as usize) as u16, + cursor_col.min(u16::MAX as usize) as u16, + )); + } + + let visual_row = &layout.rows[clicked]; + let logical_row = visual_row.logical_row; // Walk display widths to find the character cell under the pointer. - let target = (column - inner.x) as usize; let mut acc = 0usize; - let mut char_col = range.start; - for ch in logical_line - .chars() - .skip(range.start) - .take(range.end.saturating_sub(range.start)) - { - let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); - if ch_width > 0 && target < acc + ch_width { + let mut char_col = visual_row.logical_range.start; + let mut leading_zero_width_start = None; + for grapheme in &visual_row.graphemes { + if grapheme.width == 0 { + if acc == 0 { + leading_zero_width_start.get_or_insert(grapheme.logical_range.start); + } + char_col = grapheme.logical_range.end; + continue; + } + if grapheme.width > 0 && target < acc + grapheme.width { + char_col = leading_zero_width_start.unwrap_or(grapheme.logical_range.start); break; } - acc += ch_width; - char_col += 1; + leading_zero_width_start = None; + acc += grapheme.width; + char_col = grapheme.logical_range.end; } Some(( logical_row.min(u16::MAX as usize) as u16, @@ -2833,177 +3635,352 @@ pub(crate) fn composer_click_target( )) } -fn render_setup(frame: &mut Frame, state: &AppState, textarea: &TextArea, _theme: &Theme) { - let area = frame.area(); - - match state.setup_step { - 0 => { - let width = 60u16.min(area.width.saturating_sub(4)); - let height = 16u16.min(area.height.saturating_sub(2)); - let popup_area = centered_rect(area, width, height); - - let content = vec![ - Line::from(""), - Line::from(Span::styled( - " ___ ", - Style::default().fg(Color::Cyan), - )), - Line::from(Span::styled( - " / _ \\ _ __ ___ __ _ ", - Style::default().fg(Color::Cyan), - )), - Line::from(Span::styled( - " | | | | '__/ __/ _` |", - Style::default().fg(Color::Cyan), - )), - Line::from(Span::styled( - " | |_| | | | (_| (_| |", - Style::default().fg(Color::Cyan), - )), - Line::from(Span::styled( - " \\___/|_| \\___\\__,_|", - Style::default().fg(Color::Cyan), - )), - Line::from(""), - Line::from(Span::styled( - " A DeepSeek-native coding agent", - Style::default().fg(Color::White), - )), - Line::from(""), - Line::from(Span::styled( - " Let's get you set up!", - Style::default().fg(Color::Green), - )), - Line::from(""), - Line::from(Span::styled( - " Press Enter to continue...", - Style::default().fg(Color::DarkGray), - )), - ]; - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .title(" Welcome ") - .border_style(Style::default().fg(Color::Cyan)); - - let paragraph = Paragraph::new(content).block(block); - frame.render_widget(paragraph, popup_area); - } - 1 => { - let width = 60u16.min(area.width.saturating_sub(4)); - let height = 14u16.min(area.height.saturating_sub(2)); - let popup_area = centered_rect(area, width, height); - - let inner = - Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).split(Rect::new( - popup_area.x + 1, - popup_area.y + 1, - popup_area.width.saturating_sub(2), - popup_area.height.saturating_sub(2), - )); - - let content = vec![ - Line::from(""), - Line::from(Span::styled( - " Step 1: API Key", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - " Orca needs a DeepSeek API key to function.", - Style::default().fg(Color::White), - )), - Line::from(Span::styled( - " https://platform.deepseek.com/api_keys", - Style::default().fg(Color::Blue), - )), - Line::from(""), - Line::from(Span::styled( - " Paste below and press Enter:", - Style::default().fg(Color::DarkGray), - )), - ]; - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .title(" Setup ") - .border_style(Style::default().fg(Color::Cyan)); - - let paragraph = Paragraph::new(content).block(block); - frame.render_widget(paragraph, popup_area); - frame.render_widget(textarea, inner[1]); +fn render_setup( + frame: &mut Frame, + state: &AppState, + textarea: &TextArea, + theme: &Theme, +) -> Option { + let step = state.onboarding.step(); + let shell = onboarding_shell_geometry(frame.area()); + frame.render_widget(Clear, shell.panel); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(" Setup ") + .border_style(Style::default().fg(theme.border)), + shell.panel, + ); + let error = matches!(step, OnboardingStep::ApiKey | OnboardingStep::Review) + .then(|| state.onboarding.error_label()) + .flatten(); + render_onboarding_shell_text(frame, shell, step, error, theme); + + match step { + OnboardingStep::Welcome => { + render_onboarding_text( + frame, + shell.content, + theme, + &[ + "A DeepSeek-native coding agent", + "Configure local defaults for this device.", + ], + ); + None + } + OnboardingStep::Provider | OnboardingStep::Model | OnboardingStep::Theme => { + render_onboarding_options(frame, shell.content, theme, &state.onboarding.option_rows()); + None } - 2 => { - let width = 60u16.min(area.width.saturating_sub(4)); - let height = 12u16.min(area.height.saturating_sub(2)); - let popup_area = centered_rect(area, width, height); + OnboardingStep::ApiKey => render_onboarding_api_key(frame, shell.content, textarea, theme), + OnboardingStep::Review => { + render_onboarding_owned_rows( + frame, + shell.content, + theme, + state.onboarding.review_rows(), + ); + None + } + OnboardingStep::Complete => { + render_onboarding_owned_rows( + frame, + shell.content, + theme, + state.onboarding.completion_rows(), + ); + None + } + } +} - let content = vec![ - Line::from(""), - Line::from(Span::styled( - " ✓ API key saved successfully!", - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - " Saved to: ~/.orca/auth.json", - Style::default().fg(Color::DarkGray), - )), - Line::from(""), - Line::from(Span::styled( - " You're all set! Orca is ready to use.", - Style::default().fg(Color::White), - )), - Line::from(""), - Line::from(Span::styled( - " Press Enter to start...", - Style::default().fg(Color::DarkGray), - )), - ]; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct OnboardingShellGeometry { + panel: Rect, + header: Rect, + instruction: Rect, + content: Rect, + error: Rect, + footer: Rect, +} - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .title(" Setup Complete ") - .border_style(Style::default().fg(Color::Green)); +fn onboarding_panel_area(frame: Rect) -> Rect { + centered_rect( + frame, + 68.min(frame.width.saturating_sub(2)), + 18.min(frame.height.saturating_sub(2)), + ) +} - let paragraph = Paragraph::new(content).block(block); - frame.render_widget(paragraph, popup_area); - } - _ => {} +fn onboarding_shell_geometry(frame: Rect) -> OnboardingShellGeometry { + let panel = onboarding_panel_area(frame); + let inner = Rect::new( + panel.x.saturating_add(1), + panel.y.saturating_add(1), + panel.width.saturating_sub(2), + panel.height.saturating_sub(2), + ); + let constraints = match inner.height { + 0 => [ + Constraint::Length(0), + Constraint::Length(0), + Constraint::Length(0), + Constraint::Length(0), + Constraint::Length(0), + ], + 1 => [ + Constraint::Length(0), + Constraint::Length(0), + Constraint::Length(1), + Constraint::Length(0), + Constraint::Length(0), + ], + 2 => [ + Constraint::Length(0), + Constraint::Length(0), + Constraint::Min(1), + Constraint::Length(0), + Constraint::Length(1), + ], + 3 | 4 => [ + Constraint::Length(1), + Constraint::Length(0), + Constraint::Min(1), + Constraint::Length(0), + Constraint::Length(1), + ], + _ => [ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ], + }; + let rows = Layout::vertical(constraints).split(inner); + OnboardingShellGeometry { + panel, + header: rows[0], + instruction: rows[1], + content: rows[2], + error: rows[3], + footer: rows[4], } } -fn render_markdown(input: &str, width: usize) -> Vec> { - let mut opts = Options::empty(); - opts.insert(Options::ENABLE_TABLES); - let parser = Parser::new_ext(input, opts); - let mut lines: Vec> = Vec::new(); - let mut current_spans: Vec> = Vec::new(); - let mut style_stack: Vec