diff --git a/README.md b/README.md index 94fc5c76..d7c0ed9a 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,9 @@ completed `codex login`. - **Make it yours.** Embedded Husk plugins power the file tree, project search, Git workspace, and theme browser. Defaults work immediately; configuration remains optional. -- **Keep the final say.** Red gives Codex editor context, including unsaved - buffers, while staging every suggested write as an isolated proposal for - explicit review. +- **Keep the final say.** Red gives Codex bounded editor context, honors your + configured Codex tools and approval policy, and offers an isolated Review + mode when changes should be staged for explicit approval. - **Work reliably.** Atomic recovery works across platforms, and Unix detach/attach sessions preserve buffers, plugins, LSP state, and running agents across terminal or SSH disconnects. @@ -118,23 +118,27 @@ configuration, language servers, Git, CLI, and troubleshooting guidance. The [Vim compatibility matrix](docs/VIM_COMPATIBILITY.md) is the precise, versioned behavior contract. -## A safer agent workflow +## An editor-native agent workflow ![Red preparing a contextual agent prompt over the active source file](docs/images/agent-workflow.jpg) -Every agent edit is a proposal. Nothing touches your files until you accept it. - -1. **Ask.** Open the agent with `Space A`; Red includes a bounded selection or - cursor excerpt, unsaved contents, and relevant diagnostics. -2. **Review.** Codex reads editor state and stages attributed changes in an - isolated proposal filesystem. Open them with `:AgentReview`. -3. **Decide.** Accept the useful hunks and reject the rest. Codex does not - silently write into the workspace. - -The integration uses the Codex app-server directly and supports persistent -conversation, queued follow-ups, live tool progress, and explicit session -controls. Ignored, out-of-workspace, binary, and common secret files are -excluded from context. Read the +1. **Ask.** Press `Space A` to open a floating, Vim-capable prompt. Red includes + a bounded selection or cursor excerpt, unsaved contents, and relevant + diagnostics. Set `[agent] entry = "dock"` to compose directly in the dock. +2. **Continue.** The conversation opens in a responsive dock with the same + modal, multiline composer. Focus it and use `Ctrl+W H/J/K/L` to move it, + just like an editor window; `:AgentLeft`, `:AgentRight`, `:AgentTop`, and + `:AgentBottom` remain available. Moving the dock preserves your draft. +3. **Choose your workflow.** Native Agent mode honors your installed Codex + sandbox, tools, and approval policy. Set `[agent] mode = "review"` to stage + changes in an isolated proposal filesystem; inspect them with `:AgentReview` + and accept or reject each hunk explicitly. + +The integration uses the Codex app-server directly and supports resumable +conversations, live steering, queued follow-ups, tool progress, interactive +Codex approvals, clean-buffer file synchronization, and explicit session +controls. Red's automatic editor context +excludes ignored, out-of-workspace, binary, and common secret files. Read the [agent workflow and safety contract](docs/AGENT_WORKFLOW.md) for prerequisites, limits, commands, and failure behavior. diff --git a/default_config.toml b/default_config.toml index 6560d460..0ac1a2a1 100644 --- a/default_config.toml +++ b/default_config.toml @@ -47,10 +47,21 @@ disabled_plugins = [] # Remove the bundled agent plugin and reject every Codex process launch. disable_ai = false -# Direct, reviewable integration with an installed Codex CLI. +# Native integration with an installed Codex CLI. Agent mode follows your +# configured Codex tools, sandbox, and approval policy. Choose "review" for +# isolated, review-before-apply proposals or "plan" for a planning session. # Override `command` only when Codex is installed outside PATH. [agent] # command = "/path/to/codex" +mode = "agent" +# model = "gpt-5.4" +# reasoning_effort = "high" +entry = "float" +position = "right" +width_percent = 38 +height_percent = 35 +responsive = true +persistent_threads = true [search] # Preview the next match while typing / or ?. diff --git a/docs/AGENT_WORKFLOW.md b/docs/AGENT_WORKFLOW.md index d2c9219b..a358e1b2 100644 --- a/docs/AGENT_WORKFLOW.md +++ b/docs/AGENT_WORKFLOW.md @@ -1,4 +1,4 @@ -# Direct Codex workflow and safety contract +# Direct Codex workflow, modes, and safety contract Red launches the installed Codex CLI as an app-server and speaks its JSONL protocol directly. There is no ACP client, adapter, or companion executable. @@ -19,22 +19,76 @@ The check is offline. It locates `codex`, reads `codex --version`, and reports whether the installed version supports Red's app-server contract. Authentication is verified by `account/read` when the first session starts. -To use a Codex executable outside `PATH`: +Agent defaults and the Codex executable are configurable: ```toml [agent] -command = "/path/to/codex" +mode = "agent" # agent, plan, or review +entry = "float" # float or dock +position = "right" # left, right, top, or bottom +width_percent = 38 +height_percent = 35 +responsive = true +persistent_threads = true +# model = "gpt-5.4" +# reasoning_effort = "high" +# command = "/path/to/codex" ``` ## Lifecycle -Open a workspace, press `Space A` (or run `:Agent`), type a request, and press -Enter. Red lazily starts `codex app-server --stdio`, initializes the connection, -checks the account, starts an ephemeral thread, and submits turns with -`turn/start`. Follow-up text and the busy indicator render before dispatch; -follow-ups submitted during an active turn appear immediately and remain queued -in FIFO order. Assistant deltas stream into the conversation footer. `Ctrl-c` -interrupts the active turn with `turn/interrupt`. +Open a workspace, press `Space A` (or run `:Agent`), and type the first request +in the floating, buffer-backed composer. `Ctrl+Enter` immediately sends from +Insert, Normal, or Visual mode, including terminals that encode Enter as a +modified carriage return or line feed. `Alt+Enter` also sends when the terminal +reports that key combination. In Insert mode, `Enter`, `Shift+Enter`, and +`Ctrl+J` insert a newline. In Normal mode, `Enter` sends. `Esc` enters Normal +mode without discarding the draft, so `Esc`, then `Enter` is the universal +send sequence when a terminal cannot distinguish modified Enter. Set +`entry = "dock"` to open and focus the persistent dock immediately instead. +Both composers use the editor's configured `[cursor]` shapes for their own +Insert, Normal, and Visual modes, independently of the background editor. + +`Ctrl+S` keeps its normal editor meaning: save the active file. It is not an +agent send shortcut. + +When running inside tmux, enable CSI-u extended keys so tmux forwards +`Ctrl+Enter` with its modifier intact: + +```sh +tmux set-option -s extended-keys on +tmux set-option -s extended-keys-format csi-u +``` + +Without extended keys, tmux sends `Ctrl+Enter` as an ordinary `Enter`, which no +terminal application can distinguish from the insert-newline key. + +Red lazily starts `codex app-server --stdio`, initializes the connection, +checks the account, resumes the saved workspace thread when possible, and +submits turns with `turn/start`. An expired saved thread falls back once to a +new thread while retaining the current mode and prompt. After the first +submission, the conversation opens in the configured dock. Follow-ups use the +same real modal composer and are held in a bounded FIFO queue; run +`:AgentSteer` to add instructions directly to an active turn. +Assistant deltas and real tool progress stream into the conversation. `Ctrl-c` +interrupts the active turn with `turn/interrupt` without discarding the thread. + +The conversation has its own reading cursor and scroll position. In the +conversation body, use `j`/`k` or the arrow keys to read, `Ctrl+F`/`Ctrl+B` +to page, and `g`/`G` to reach the beginning or end. `Tab` and `Shift+Tab` +select links; `Enter` opens the selected link. Press `i` or `a`, or click the footer +to return to the composer. `Esc` inside the composer enters Normal mode; +`Ctrl+C` leaves the composer while preserving its draft. In conversation +reading mode, `Esc` returns focus to the editor. + +With the conversation focused, use `Ctrl+W H`, `Ctrl+W J`, `Ctrl+W K`, or +`Ctrl+W L` to move it to the left, bottom, top, or right, just as with an +ordinary editor window. `:AgentLeft`, `:AgentBottom`, `:AgentTop`, and +`:AgentRight` remain available. Moving the conversation preserves its draft, +history, reading cursor, and focus; adaptive layout preserves usable editor +space on narrow terminals. The conversation and floating prompt use the +editor's background, with theme color confined to message text, separators, +status, and the shortcut strip. If Codex cannot start, Red preserves the prompt and offers a retry action. Install or update Codex, run `codex login`, then retry without retyping. @@ -42,21 +96,31 @@ Install or update Codex, run `codex login`, then retry without retyping. The app-server process is owned by the detachable editor core, so disconnecting and reattaching does not intentionally replace a healthy process. -## Reviewable editing +## Native Agent, Plan, and isolated Review modes + +The default `mode = "agent"` uses the effective configuration of the installed +Codex CLI. Native commands, direct workspace edits, configured MCP servers, +apps, connectors, plugins, skills, and hooks are available only when the +user's Codex configuration and managed policy allow them. Red does not widen +the Codex sandbox or auto-accept an approval. Native command, file-change, and +permission requests are presented to the user with the exact choices supplied +by Codex; closing an approval without choosing denies it. Completed native file +changes reload clean open buffers and update their editor and LSP state. Dirty +buffers are never overwritten: Red retains the unsaved contents and reports the +file conflict in the conversation. -Every Codex thread is started with: +Set `mode = "plan"` to request Codex's planning collaboration mode. Set +`mode = "review"` to restore Red's strictly isolated editing contract. Review +mode starts each Codex thread with: -- `sandbox = "read-only"` -- `approvalPolicy = "never"` -- no execution environments -- configured MCP servers disabled -- apps, connectors, plugins, orchestrator MCP, and notifications disabled -- hooks disabled unless the managed Codex policy requires them; when required, - Codex may also load trusted user, workspace, or plugin hooks -- Red's bounded dynamic tools and reviewable-edit instructions +- `sandbox = "read-only"` and `approvalPolicy = "never"`; +- no native execution environments; +- configured MCP servers, apps, connectors, and plugins disabled; +- hooks disabled unless managed Codex policy requires trusted hooks; and +- Red's bounded dynamic tools and reviewable-edit instructions. -Native command, file-change, and permission escalation requests are denied. -Red never asks Codex to edit the workspace directly. +In Review mode, native command, file-change, and permission requests are +denied. Red's write tools stage proposals; they do not edit workspace files. Codex receives nine dynamic tools: @@ -83,9 +147,11 @@ Content search is unavailable on platforms without that safe read boundary; Codex must use `read_file` through Red instead. Run `:AgentReview` to inspect pending files and hunks. Accepting a proposal -passes through the editor's transaction boundary and receives agent attribution. -Rejecting it discards only the selected proposal. Unaccepted proposals never -mutate a visible buffer or disk. +passes through the editor's transaction boundary and receives agent +attribution. Rejecting it discards only the selected proposal. Unaccepted +review-mode proposals never mutate a visible buffer or disk. Native Agent +mode can separately perform direct edits according to Codex's configured +sandbox and approval policy. ## Limits and failure behavior @@ -100,7 +166,7 @@ preserves the submitted prompt for retry. Dynamic tools are part of Codex app-server's experimental capability surface. Red pins a minimum tested CLI version and fails closed when the required -protocol is unavailable; it does not fall back to `codex exec` or native edits. +protocol is unavailable; it does not silently fall back to `codex exec`. ## Commands @@ -108,6 +174,11 @@ protocol is unavailable; it does not fall back to `codex exec` or native edits. | --- | --- | | `:Agent` / `:AgentPrompt` | Open the prompt composer. | | `:AgentOpen` | Show and focus the conversation pane without opening a prompt. | +| `:AgentLeft` / `:AgentRight` | Dock the conversation beside the editor. | +| `:AgentTop` / `:AgentBottom` | Dock the conversation above or below the editor. | +| `:AgentModels` | List models available to the active Codex session. | +| `:AgentSessions` | List resumable conversations for the workspace. | +| `:AgentSteer` | Add instructions directly to a running agent turn. | | `:AgentCancel` | Interrupt the active Codex turn. | | `:AgentClear` | Clear visible conversation while retaining current context. | | `:AgentNew` | Close the current thread and start a new one. | diff --git a/docs/PLUGIN_API.md b/docs/PLUGIN_API.md index c9547ba7..c4444b69 100644 --- a/docs/PLUGIN_API.md +++ b/docs/PLUGIN_API.md @@ -1,6 +1,6 @@ # Husk plugin compatibility -Red host API version `0.4.0` is defined by +Red host API version `0.4.1` is defined by [`src/plugin/host_api.json`](../src/plugin/host_api.json). That file is the canonical, machine-readable list of execute actions, request actions, signatures, and introduction versions. Runtime dispatch and the bundled-plugin corpus are checked against it in tests. @@ -110,7 +110,21 @@ red::execute("OpenComposer", "Agent prompt", draft, history, ComposerHandlers { }); ``` -The host owns multiline editing, wrapping, cursor movement, and history navigation; it does not send a callback for each keystroke. Input is limited to 128 KiB so an escaping-heavy prompt remains within the Codex app-server frame limit; an oversized paste leaves the current draft intact and shows a validation message. Enter submits, `Ctrl-j` or Shift-Enter inserts a newline, Escape or `Ctrl-c` cancels, and `Ctrl-p` / `Ctrl-n` moves through the supplied history while preserving the current draft. +The host owns a real, in-memory editor buffer, Vim-style normal/insert/visual +modes, multiline editing, wrapping, cursor movement, undo/redo, operators, text +objects, and history navigation; it does not send a callback for each +keystroke. Input is limited to 128 KiB so an escaping-heavy prompt remains +within the Codex app-server frame limit; an oversized paste leaves the current +draft intact and shows a validation message. `Ctrl+Enter` submits immediately +in any composer mode; both modified carriage-return and line-feed terminal +encodings are supported. `Alt+Enter` also submits when the terminal reports +that key combination. In insert mode, `Enter`, `Shift+Enter`, and `Ctrl+J` +insert a newline; in normal mode, `Enter` submits. `Escape` switches from insert or +visual mode to normal mode, so `Escape`, then `Enter` provides a universal +send sequence. `Ctrl+C` cancels the floating composer. `Ctrl+P` and `Ctrl+N` +move through the supplied history while preserving the current draft. +`Ctrl+S` remains the editor's save shortcut; it is not an agent submission +binding. `OpenComposer` was introduced in host API `0.3.0`. The numeric-ID `OpenAgentComposer` API and its `composer:submitted:` / `composer:cancelled:` events remain available for compatibility with `0.2.0` plugins. @@ -118,11 +132,81 @@ The host owns multiline editing, wrapping, cursor movement, and history navigati `AgentPrompt` automatically attaches bounded editor context containing the active visual selection or a roughly 80-line cursor excerpt, unsaved-state metadata, cursor/range, and intersecting diagnostics. Files outside the workspace, ignored paths, common credential/secret filenames, and binary buffers are omitted. Plugins that need to inspect or explicitly override this context can call `GetAgentContext(callback)` and `AgentPromptWithContext(session_id: String, text: String, context: Json)`; the context object accepts `uri` and `text` fields and is included in the direct Codex turn. +## Native agent conversations + +Host API `0.4.1` adds typed, bounded operations for native Codex conversation +management: + +- `AgentResumeSession(session_id: String, cwd: String)` resumes an existing + workspace-scoped Codex thread. +- `AgentSteer(session_id: String, text: String)` submits additional instructions + to an existing active turn. +- `AgentListModels(session_id: String)` requests the available model catalog. +- `AgentListSessions(session_id: String, cwd: String)` requests up to 50 saved + conversations in the current workspace. +- `AgentSetModel(session_id: String, model: String, reasoning_effort?: String)` + changes the model for future turns without restarting the Codex bridge, + discarding the current thread, or overriding the user's approval policy. +- `AgentSetReasoningEffort(effort: String)` configures the reasoning effort + used by the next Codex session. +- `SetAgentPosition(position: String)` moves the existing agent panel to + `left`, `right`, `top`, or `bottom` without replacing its source blocks, + composer, draft, or focus. + +Catalog results arrive as session-scoped `agent:activity` events with +`session_update: "models"` or `session_update: "sessions"`; successful model +selection arrives as `session_update: "model_selected"`, and token usage +arrives as `session_update: "token_usage"`. Completed native file changes +reload clean open buffers. A change that overlaps unsaved editor contents +preserves the buffer and emits a session-scoped `agent:file_conflict` event. +User approval requests retain the exact app-server choices and are denied when +the session is inactive, the request is cancelled, or the response cannot be +delivered. Red's `disable_ai` switch remains authoritative over all +conversation and process-launch operations. + ## Text panels `CreateTextPanel`, `UpdateTextPanel`, and `AppendTextPanel` provide a source-backed conversation surface. `TextPanelBlock` accepts an `id`, `kind` (`user`, `agent`, `error`, or `text`), `format` (`plain` or `markdown`), and `text`; the host preserves the source while wrapping and rendering it for the current panel width. These calls were introduced in host API `0.2.0`. -`PanelConfig` may include `composer: Json { placeholder: String, rows: i32 }` for a persistent footer composer and `header_actions: [Json { id: String, label: String, compact_label?: String }]` for clickable, right-aligned header controls. Row panels can also set `surface: ThemeStyleSpec` and `border: ThemeStyleSpec` to resolve theme-aware panel foreground, background, and separator colors without affecting other panels. Header actions emit `panel:event:` using their configured `id`; compact labels are selected automatically on narrow panels, with the rightmost actions retained when space is especially limited. Focus the footer with `FocusTextPanelComposer(id)`, update its enabled/status state with `SetTextPanelComposerState(id, enabled, status?)`, and clear its draft with `ClearTextPanelComposer(id)`. A focused composer supports Unicode-safe editing, paste, wrapping, click-to-position cursor movement, `Ctrl-p`/`Ctrl-n` local history, Enter to submit, and `Ctrl-j` or Shift-Enter for a newline. It emits `panel:event:` with `action: "submit"` and the complete `text`; other footer actions include `composer_focus`, `composer_blur`, `interrupt`, `clear`, `new`, `history`, and `close`. `SetPanelVisible(id, visible)` hides or restores a panel without discarding its blocks, scroll position, or draft. Replacing text-panel blocks with an empty list resets scrolling and restores tail-following. Footer panels shrink on narrow terminals while preserving an editor viewport. +`PanelConfig.side` accepts `left`, `right`, `top`, and `bottom`. Its existing +`width` field describes the dock's thickness: columns for a left/right panel +and rows for a top/bottom panel. This preserves compatibility with existing +panel plugins and configuration records. `PanelConfig` may include +`composer: Json { placeholder: String, rows: i32 }` for a persistent footer +composer and `header_actions: [Json { id: String, label: String, +compact_label?: String }]` for clickable, right-aligned header controls. Row +panels can also set `surface: ThemeStyleSpec` and `border: ThemeStyleSpec` to +resolve theme-aware panel foreground, background, and separator colors without +affecting other panels. The bundled agent requests the editor background for +its text panel and reserves contrasting background color for the bottom +shortcut strip; message roles, links, status, and separators remain +theme-colored without applying full-width transcript backgrounds. + +Header actions emit `panel:event:` using their configured `id`; compact +labels are selected automatically on narrow panels, with the rightmost actions +retained when space is especially limited. Focus the footer with +`FocusTextPanelComposer(id)`, update its enabled/status state with +`SetTextPanelComposerState(id, enabled, status?)`, and clear its draft with +`ClearTextPanelComposer(id)`. The focused composer shares the floating +composer's real modal editor, Unicode-safe editing, undo, paste, wrapping, +click-to-position cursor, history, and mode-aware send/newline controls. It +emits `panel:event:` with `action: "submit"` and the complete `text`; +other footer actions include `composer_focus`, `composer_blur`, `interrupt`, +`clear`, `new`, `history`, and `close`. When the conversation body has focus, +its independent reading cursor supports `j`/`k`, arrow keys, +`Ctrl+F`/`Ctrl+B`, `g`/`G`, link selection with `Tab`/`Shift+Tab`, and link +activation with `Enter`. Press `i` or `a`, click the footer, or call +`FocusTextPanelComposer(id)` to enter the composer. `Escape` inside the +composer enters normal mode; `Ctrl+C` blurs the composer without discarding +its draft. `Escape` while reading returns focus to the editor. When the +bundled agent conversation is focused, `Ctrl+W H`, `Ctrl+W J`, `Ctrl+W K`, +and `Ctrl+W L` move its existing panel to the left, bottom, top, and right +without recreating its composer or discarding its draft. +`SetPanelVisible(id, visible)` hides or restores a panel without +discarding its blocks, scroll position, or draft. Replacing text-panel blocks +with an empty list resets scrolling and restores tail-following. Responsive +agent panels preserve the editor viewport and fall back to a bottom dock when +a requested left or right panel cannot fit safely. Codex app-server updates other than assistant text chunks are forwarded to plugins as `agent:activity` with the normalized `update` payload. Core editor-tool calls also emit this event with `session_update: "editor_tool"`, `status: "in_progress"`, and a concise `title` such as `Opening src/main.rs` or `Proposing 2 edit(s) in src/main.rs`. This allows status/tool/plan progress to be displayed without treating it as transcript text. diff --git a/docs/adr/0003-direct-codex-app-server.md b/docs/adr/0003-direct-codex-app-server.md index b0b6c54b..c56e0207 100644 --- a/docs/adr/0003-direct-codex-app-server.md +++ b/docs/adr/0003-direct-codex-app-server.md @@ -12,9 +12,12 @@ generic ACP adapters, the OpenAI Responses companion, or the Codex ACP translation companion. Red owns app-server process lifecycle, JSONL framing, request correlation, -Codex threads and turns, cancellation, and dynamic-tool dispatch. The existing -proposal workspace remains the only supported write path. Threads run read-only -with native approvals denied and configured extension surfaces disabled. +Codex threads and turns, cancellation, native approvals, and dynamic-tool +dispatch. Native Agent mode follows the user's configured Codex sandbox, +approvals, native tools, and extension surfaces. The existing proposal +workspace remains available as the isolated, explicitly selected Review mode; +that mode runs read-only with native approvals denied and configured extension +surfaces disabled. Codex dynamic tools are currently experimental. Red requires Codex CLI 0.144.1 or newer, opts into the experimental app-server capability, and fails closed if @@ -24,13 +27,14 @@ the required contract is unavailable. The removed Codex companion already translated ACP into app-server calls. Moving that client into core removes a process and protocol boundary while -preserving persistent conversations, streaming, cancellation, editor-aware -tools, and reviewable proposals. +preserving resumable conversations, streaming, cancellation, editor-aware +tools, configured native capabilities, and optional reviewable proposals. `codex exec` is not an automatic fallback. Its one-shot automation surface -cannot provide Red's bidirectional live editor tools and proposal callbacks -without a workspace mirror and post-hoc diff import, which would weaken unsaved -buffer semantics and the review guarantee. +cannot provide Red's bidirectional live editor tools, approvals, resumable +threads, and proposal callbacks without a workspace mirror and post-hoc diff +import, which would weaken unsaved-buffer semantics and the Review-mode +guarantee. ## Consequences diff --git a/docs/plugin_api_changes.json b/docs/plugin_api_changes.json index 900ef8fe..8aa2eb44 100644 --- a/docs/plugin_api_changes.json +++ b/docs/plugin_api_changes.json @@ -1,6 +1,12 @@ { - "api_version": "0.4.0", + "api_version": "0.4.1", "changes": [ + { + "version": "0.4.1", + "kind": "introduced", + "symbols": ["AgentResumeSession", "AgentSteer", "AgentListModels", "AgentListSessions", "AgentSetModel", "AgentSetReasoningEffort", "SetAgentPosition"], + "migration_note": "docs/PLUGIN_API.md#native-agent-conversations" + }, { "version": "0.4.0", "kind": "introduced", diff --git a/plugins/agent.hk b/plugins/agent.hk index 6ab40b42..5c155667 100644 --- a/plugins/agent.hk +++ b/plugins/agent.hk @@ -30,6 +30,48 @@ pub fn activate() { description: "Show and focus the conversation pane without opening a prompt", aliases: ["show chat", "open chat"], }); + red::add_command("AgentLeft", dock_left, Json { + title: "Move agent to the left", + category: "Agent", + description: "Dock the current agent conversation on the left", + aliases: ["agent left", "chat left"], + }); + red::add_command("AgentRight", dock_right, Json { + title: "Move agent to the right", + category: "Agent", + description: "Dock the current agent conversation on the right", + aliases: ["agent right", "chat right"], + }); + red::add_command("AgentTop", dock_top, Json { + title: "Move agent to the top", + category: "Agent", + description: "Dock the current agent conversation above the editor", + aliases: ["agent top", "chat top"], + }); + red::add_command("AgentBottom", dock_bottom, Json { + title: "Move agent to the bottom", + category: "Agent", + description: "Dock the current agent conversation below the editor", + aliases: ["agent bottom", "chat bottom"], + }); + red::add_command("AgentModels", list_models, Json { + title: "List Codex models", + category: "Agent", + description: "Show the models available to the active Codex conversation", + aliases: ["agent models", "codex models"], + }); + red::add_command("AgentSessions", list_sessions, Json { + title: "List agent conversations", + category: "Agent", + description: "Show saved Codex conversations for this workspace", + aliases: ["agent sessions", "chat history"], + }); + red::add_command("AgentSteer", steer, Json { + title: "Steer the active agent turn", + category: "Agent", + description: "Add instructions to a running Codex turn without queuing a new turn", + aliases: ["steer agent", "guide agent"], + }); red::add_command("AgentCancel", cancel, Json { title: "Cancel agent request", category: "Agent", @@ -78,6 +120,8 @@ pub fn activate() { red::on("agent:proposal_applied", proposals_changed); red::on("agent:proposal_conflict", proposal_conflict); red::on("agent:permission_requested", permission_requested); + red::on("agent:file_conflict", file_conflict); + red::on("agent:open_dock", dock_entry_requested); red::on("agent:transcript_restored", transcript_restored); red::on("workspace:event:agent-review", review_event); red::on("workspace:event:agent-history", history_event); @@ -86,6 +130,7 @@ pub fn activate() { red::on("timeout:callback", stream_timeout); red::on("panel:event:agent-conversation", panel_event); red::state_set("agent_session_id", ""); + red::state_set("agent_pending_resume_id", ""); red::state_set("agent_panel_created", false); red::state_set("agent_panel_open", false); red::state_set("agent_focus_panel_on_first_prompt", false); @@ -117,6 +162,7 @@ pub fn activate() { red::state_set("agent_activity_rows", []); red::state_set("agent_activity_block_id", ""); red::state_set("agent_thought", ""); + red::state_set("agent_token_usage", red::null()); } fn start() { @@ -175,12 +221,193 @@ fn open_conversation() { red::execute("FocusPanel", "agent-conversation"); } +fn dock_entry_requested(event: Json) { + ensure_conversation_panel(); + render_conversation(); + red::execute("FocusTextPanelComposer", "agent-conversation"); +} + +fn dock_left() { dock_conversation("left"); } +fn dock_right() { dock_conversation("right"); } +fn dock_top() { dock_conversation("top"); } +fn dock_bottom() { dock_conversation("bottom"); } + +fn dock_conversation(position: String) { + red::execute("SetAgentPosition", position); + open_conversation(); +} + +fn list_models() { + let session_id = red::string(red::state("agent_session_id"), ""); + if session_id == "" { + red::execute("Print", "Start an agent conversation before listing models"); + return; + } + red::execute("AgentListModels", session_id); +} + +fn list_sessions() { + if red::string(red::state("agent_session_id"), "") == "" { + red::execute("Print", "Start an agent conversation before listing saved sessions"); + return; + } + red::request("GetConfig", sessions_cwd_loaded, "cwd"); +} + +fn sessions_cwd_loaded(result: Json) { + let session_id = red::string(red::state("agent_session_id"), ""); + if session_id != "" { + red::execute("AgentListSessions", session_id, red::string(result.value, ".")); + } +} + +fn steer() { + let session_id = red::string(red::state("agent_session_id"), ""); + if session_id == "" || !red::state_bool("agent_turn_active") { + red::execute("Print", "Start an agent turn before steering it"); + return; + } + red::execute( + "OpenComposer", + "Steer agent", + "", + red::state("agent_prompt_history"), + ComposerHandlers { + submitted: steer_submitted, + cancelled: prompt_cancelled, + } + ); +} + +fn steer_submitted(text: String) { + let session_id = red::string(red::state("agent_session_id"), ""); + if red::trim(text) == "" { + return; + } + if session_id == "" || !red::state_bool("agent_turn_active") { + red::execute("Print", "The agent turn completed before it could be steered"); + return; + } + red::execute("AgentSteer", session_id, text); +} + +fn show_models(update: Json) { + let items = []; + for model in update.models { + let id = red::string(model.id, red::string(model.model, "")); + if id == "" { + continue; + } + let label = red::string(model.display_name, id); + items = red::push(items, PickerItem { + id: id, + label: label, + kind: "Model", + annotation: id, + detail: red::string(model.description, ""), + data: Json { + model: id, + reasoning_effort: red::string(model.default_reasoning_effort, ""), + }, + }); + } + if red::len(items) == 0 { + red::execute("Print", "Codex did not report any available models"); + return; + } + red::execute("OpenPicker", "Codex model", items, PickerOptions { + placeholder: "Choose a model for the current conversation", + status: red::len(items) + " available models", + presentation: "compact", + }, PickerHandlers { + selected: model_selected, + cancelled: catalog_cancelled, + }); +} + +fn model_selected(item: PickerItem) { + if red::state_bool("agent_turn_active") { + red::execute("Print", "Stop or finish the active turn before changing the Codex model"); + return; + } + let session_id = red::string(red::state("agent_session_id"), ""); + if session_id == "" { + red::execute("Print", "Start an agent conversation before changing the Codex model"); + return; + } + let model = red::string(item.data.model, item.id); + red::execute( + "AgentSetModel", + session_id, + model, + red::string(item.data.reasoning_effort, "") + ); +} + +fn show_sessions(update: Json) { + let items = []; + for session in update.sessions { + let id = red::string(session.id, red::string(session.thread_id, "")); + if id == "" { + continue; + } + let preview = red::string(session.preview, red::string(session.name, id)); + items = red::push(items, PickerItem { + id: id, + label: preview, + kind: "Conversation", + annotation: id, + data: Json { session_id: id }, + }); + } + if red::len(items) == 0 { + red::execute("Print", "No saved Codex conversations were found for this workspace"); + return; + } + red::execute("OpenPicker", "Agent conversations", items, PickerOptions { + placeholder: "Resume a saved workspace conversation", + status: red::len(items) + " saved conversations", + presentation: "compact", + }, PickerHandlers { + selected: session_selected, + cancelled: catalog_cancelled, + }); +} + +fn session_selected(item: PickerItem) { + let session_id = red::string(item.data.session_id, item.id); + if session_id == red::string(red::state("agent_session_id"), "") { + red::execute("Print", "This agent conversation is already active"); + return; + } + red::state_set("agent_pending_resume_id", session_id); + red::request("GetConfig", resume_cwd_loaded, "cwd"); +} + +fn resume_cwd_loaded(result: Json) { + let session_id = red::string(red::state("agent_pending_resume_id"), ""); + red::state_set("agent_pending_resume_id", ""); + if session_id != "" { + red::execute("AgentResumeSession", session_id, red::string(result.value, ".")); + } +} + +fn catalog_cancelled(event: PickerCancelled) {} + fn ensure_conversation_panel() { if !red::state_bool("agent_panel_created") { red::execute("CreateTextPanel", "agent-conversation", PanelConfig { side: "right", width: 62, title: "Agent", + surface: ThemeStyleSpec { + foreground: ["sideBar.foreground", "editor.foreground"], + background: ["editor.background"], + }, + border: ThemeStyleSpec { + foreground: ["sideBar.border", "panel.border", "editorLineNumber.foreground"], + background: ["editor.background"], + }, composer: Json { placeholder: "Ask a follow-up…", rows: 3 }, header_actions: [ Json { id: "clear", label: "Clear", compact_label: "C" }, @@ -379,7 +606,17 @@ fn activity(event: Json) { } let update = event.update; let kind = red::string(update.session_update, ""); - if kind == "agent_thought_chunk" { + if kind == "models" { + show_models(update); + } else if kind == "sessions" { + show_sessions(update); + } else if kind == "token_usage" { + red::state_set("agent_token_usage", update.token_usage); + } else if kind == "model_selected" { + red::execute("Print", "Codex model set to " + red::string(update.model, "the selected model")); + } else if kind == "steer" { + red::execute("Print", "Follow-up added to the active Codex turn"); + } else if kind == "agent_thought_chunk" { if update.content != red::null() { let text = red::string(update.content.text, ""); if text != "" { @@ -668,7 +905,8 @@ fn update(event: Json) { let transcript = red::state("agent_transcript"); let block_id = red::string(red::state("agent_stream_block_id"), ""); - if !red::state_bool("agent_streaming") { + let first_delta = !red::state_bool("agent_streaming"); + if first_delta { if transcript != "" && !red::ends_with(transcript, "\n") { transcript = transcript + "\n"; } @@ -714,10 +952,12 @@ fn update(event: Json) { red::state_set("agent_stream_delta", red::string(red::state("agent_stream_delta"), "") + delta); let timer = red::string(red::state("agent_stream_timer"), ""); - if timer != "" { - red::execute("CancelTimeout", timer); + if timer == "" { + if first_delta { + flush_stream(block_id); + } + red::state_set("agent_stream_timer", red::execute("SetTimeout", 50)); } - red::state_set("agent_stream_timer", red::execute("SetTimeout", 50)); } fn completed(event: Json) { @@ -1002,7 +1242,7 @@ fn render_conversation() { id: "empty", kind: "activity", format: "plain", - text: "No messages yet.\n\nEnter sends · ^J adds a line\na edits from the panel · Space A opens the prompt\n^C stops a running turn", + text: "No messages yet.\n\nAsk about the current file, explain a selection,\nor request a change.\n\nCtrl+Enter sends · Alt+Enter when supported\nEnter, Shift+Enter, or Ctrl+J adds a line\nEsc, then Enter always sends\nj/k reads · i/a edits · Space A opens the prompt\nCtrl+W H/J/K/L moves the pane\nCtrl+C stops a running turn", }]; } red::execute("UpdateTextPanel", "agent-conversation", blocks); @@ -1418,6 +1658,15 @@ fn permission_requested(event: Json) { }); } +fn file_conflict(event: Json) { + if !is_current_session_event(event) { + return; + } + let message = red::string(event.message, "Codex changed a file with unsaved editor contents"); + append_transcript("Error", message); + red::execute("Print", message); +} + fn permission_selected(item: PickerItem) { red::execute( "AgentPermissionResponse", diff --git a/src/agent_tools.rs b/src/agent_tools.rs index 31c3c14c..5c2219dd 100644 --- a/src/agent_tools.rs +++ b/src/agent_tools.rs @@ -1,5 +1,7 @@ //! Strict editor-tool contract shared by Red and Codex dynamic tools. +use std::{ffi::OsStr, path::Path}; + use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use tokio::sync::{mpsc, oneshot}; @@ -7,6 +9,85 @@ use tokio::sync::{mpsc, oneshot}; /// Maximum number of edits accepted in one atomic proposal operation. pub const MAX_EDITOR_EDITS: usize = 128; +/// Returns whether a workspace path names credentials or other sensitive data. +pub(crate) fn agent_path_is_sensitive(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(OsStr::to_str) else { + return true; + }; + let name = name.to_ascii_lowercase(); + name == ".env" + || name.starts_with(".env.") + || name.contains("secret") + || name.contains("credential") + || matches!(name.as_str(), "id_rsa" | "id_ed25519") + || matches!( + path.extension() + .and_then(OsStr::to_str) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref(), + Some("pem" | "key" | "p12" | "pfx") + ) +} + +/// Evaluates workspace-local ignore files for an agent-visible absolute path. +pub(crate) fn agent_path_is_ignored(path: &Path, root: &Path) -> bool { + let mut ignored = false; + let mut directories = path + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|directory| directory.starts_with(root)) + .collect::>(); + directories.reverse(); + for directory in directories { + for name in [".gitignore", ".ignore"] { + let (matcher, _) = ignore::gitignore::Gitignore::new(directory.join(name)); + match matcher.matched_path_or_any_parents(path, /* is_dir */ false) { + ignore::Match::Ignore(_) => ignored = true, + ignore::Match::Whitelist(_) => ignored = false, + ignore::Match::None => {} + } + } + } + let mut builder = ignore::gitignore::GitignoreBuilder::new(root); + builder.add(root.join(".git/info/exclude")); + if let Ok(exclude) = builder.build() { + match exclude.matched_path_or_any_parents(path, /* is_dir */ false) { + ignore::Match::Ignore(_) => ignored = true, + ignore::Match::Whitelist(_) => ignored = false, + ignore::Match::None => {} + } + } + ignored +} + +/// Applies the same fail-closed path-disclosure policy to every Red agent tool. +pub(crate) fn ensure_agent_path_disclosable(root: &Path, path: &Path) -> anyhow::Result<()> { + let full_path = if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + }; + let relative = full_path + .strip_prefix(root) + .map_err(|_| anyhow::anyhow!("agent path is outside the workspace"))?; + anyhow::ensure!( + relative + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))), + "agent path contains an unsafe workspace component" + ); + anyhow::ensure!( + !agent_path_is_sensitive(&full_path), + "agent cannot disclose a sensitive file" + ); + anyhow::ensure!( + !agent_path_is_ignored(&full_path, root), + "agent cannot disclose an ignored file" + ); + Ok(()) +} + /// A zero-based UTF-16 position, compatible with LSP coordinates. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -377,6 +458,77 @@ mod tests { EditorPosition { line, character } } + #[test] + fn sensitive_agent_paths_fail_closed() { + let workspace = tempfile::tempdir().unwrap(); + + for name in [ + ".env", + ".env.local", + "service-secret.json", + "credentials.json", + "id_rsa", + "id_ed25519", + "certificate.pem", + "private.key", + "identity.p12", + "identity.pfx", + ] { + let error = ensure_agent_path_disclosable(workspace.path(), Path::new(name)) + .expect_err("sensitive agent paths must be rejected"); + assert!(error.to_string().contains("sensitive"), "{name}: {error}"); + } + + ensure_agent_path_disclosable(workspace.path(), Path::new("src/main.rs")).unwrap(); + } + + #[test] + fn agent_paths_honor_workspace_and_nested_ignore_rules() { + let workspace = tempfile::tempdir().unwrap(); + std::fs::write(workspace.path().join(".gitignore"), "ignored.rs\n").unwrap(); + std::fs::write(workspace.path().join(".ignore"), "local.rs\n").unwrap(); + let nested = workspace.path().join("nested"); + std::fs::create_dir(&nested).unwrap(); + std::fs::write(nested.join(".gitignore"), "blocked.rs\n").unwrap(); + + for path in ["ignored.rs", "local.rs", "nested/blocked.rs"] { + let error = ensure_agent_path_disclosable(workspace.path(), Path::new(path)) + .expect_err("ignored agent paths must be rejected"); + assert!(error.to_string().contains("ignored"), "{path}: {error}"); + } + + ensure_agent_path_disclosable(workspace.path(), Path::new("nested/included.rs")).unwrap(); + } + + #[test] + fn agent_paths_honor_git_info_exclude() { + let workspace = tempfile::tempdir().unwrap(); + let info = workspace.path().join(".git/info"); + std::fs::create_dir_all(&info).unwrap(); + std::fs::write(info.join("exclude"), "machine-local.rs\n").unwrap(); + + assert!( + ensure_agent_path_disclosable(workspace.path(), Path::new("machine-local.rs")).is_err() + ); + ensure_agent_path_disclosable(workspace.path(), Path::new("shared.rs")).unwrap(); + } + + #[test] + fn agent_paths_reject_workspace_escape_and_non_normal_components() { + let workspace = tempfile::tempdir().unwrap(); + + for path in [Path::new("../outside.rs"), Path::new("src/../outside.rs")] { + assert!(ensure_agent_path_disclosable(workspace.path(), path).is_err()); + } + + let outside = tempfile::tempdir().unwrap(); + assert!(ensure_agent_path_disclosable( + workspace.path(), + &outside.path().join("outside.rs") + ) + .is_err()); + } + #[test] fn tool_schemas_are_strict_and_bounded() { for schema_key in ["parameters", "inputSchema"] { diff --git a/src/agent_workspace.rs b/src/agent_workspace.rs index 59e22cd5..2abf884d 100644 --- a/src/agent_workspace.rs +++ b/src/agent_workspace.rs @@ -5,7 +5,7 @@ //! returned contents through the editor's transaction boundary. use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, path::{Component, Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, @@ -25,11 +25,15 @@ use tokio::{ use uuid::Uuid; use crate::{ - agent_tools::{apply_text_edits, EditorTextEdit, EditorToolRequest, PendingEditorTool}, + agent_tools::{ + apply_text_edits, ensure_agent_path_disclosable, EditorTextEdit, EditorToolRequest, + PendingEditorTool, + }, codex::CodexToolHost, }; const MAX_PROPOSAL_CONTENT_BYTES: usize = 960 * 1024; +const MAX_AGENT_OVERLAY_FILES: usize = 4096; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct VisibleFile { @@ -197,6 +201,49 @@ impl ProposalWorkspace { self.generation } + /// Lists safe editor-visible and session-proposed contents for agent search. + pub(crate) fn agent_overlay_files( + &self, + session_id: &str, + ) -> anyhow::Result> { + self.ensure_root_is_current()?; + let mut files = BTreeMap::new(); + for (path, visible) in &self.visible { + if visible.contents.len() > MAX_PROPOSAL_CONTENT_BYTES + || ensure_agent_path_disclosable(&self.root, path).is_err() + { + continue; + } + let Ok(relative) = path.strip_prefix(&self.root) else { + continue; + }; + files.insert( + relative.to_string_lossy().replace('\\', "/"), + visible.contents.clone(), + ); + if files.len() == MAX_AGENT_OVERLAY_FILES { + break; + } + } + if let Some(session) = self.sessions.get(session_id) { + for (path, proposal) in &session.files { + if proposal.proposed_contents.len() > MAX_PROPOSAL_CONTENT_BYTES + || ensure_agent_path_disclosable(&self.root, path).is_err() + { + continue; + } + let Ok(relative) = path.strip_prefix(&self.root) else { + continue; + }; + let relative = relative.to_string_lossy().replace('\\', "/"); + if files.len() < MAX_AGENT_OVERLAY_FILES || files.contains_key(&relative) { + files.insert(relative, proposal.proposed_contents.clone()); + } + } + } + Ok(files.into_iter().collect()) + } + #[must_use] /// Captures visible files, session proposals, and ownership metadata. pub fn snapshot(&self) -> ProposalWorkspaceSnapshot { @@ -1038,11 +1085,13 @@ impl CodexToolHost for ProposalToolHost { session_id: &str, path: &str, ) -> anyhow::Result { - let contents = self + let mut workspace = self .workspace .lock() - .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))? - .read(session_id, Path::new(path), None, None)?; + .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))?; + let path = workspace.resolve_tool_path(path)?; + ensure_agent_path_disclosable(workspace.root(), &path)?; + let contents = workspace.read(session_id, &path, None, None)?; Ok(serde_json::json!({"content": contents})) } @@ -1052,11 +1101,21 @@ impl CodexToolHost for ProposalToolHost { path: &str, content: String, ) -> anyhow::Result { + let mut workspace = self + .workspace + .lock() + .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))?; + let path = workspace.resolve_tool_path(path)?; + ensure_agent_path_disclosable(workspace.root(), &path)?; + workspace.write(session_id, &path, content)?; + Ok(serde_json::json!({})) + } + + async fn overlay_files(&mut self, session_id: &str) -> anyhow::Result> { self.workspace .lock() .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))? - .write(session_id, Path::new(path), content)?; - Ok(serde_json::json!({})) + .agent_overlay_files(session_id) } async fn editor_tool( @@ -1247,6 +1306,84 @@ mod tests { (temp, workspace, path) } + #[tokio::test] + async fn proposal_host_resolves_relative_paths_against_its_workspace() { + let (temp, workspace, path) = workspace(); + let shared = Arc::new(Mutex::new(workspace)); + let mut host = ProposalToolHost::new(Arc::clone(&shared)); + + let visible = host.read_file("session-1", "src.rs").await.unwrap(); + assert_eq!(visible["content"], "one\nunsaved\nthree\n"); + + host.write_file("session-1", "src.rs", "one\nproposed\nthree\n".to_string()) + .await + .unwrap(); + + let proposed = host.read_file("session-1", "src.rs").await.unwrap(); + assert_eq!(proposed["content"], "one\nproposed\nthree\n"); + assert_eq!( + std::fs::read_to_string(temp.path().join("src.rs")).unwrap(), + "one\ntwo\nthree\n" + ); + assert_eq!( + shared + .lock() + .unwrap() + .read("session-1", &path, None, None) + .unwrap(), + "one\nproposed\nthree\n" + ); + } + + #[tokio::test] + async fn proposal_host_rejects_unsafe_paths_without_creating_proposals() { + let (temp, workspace, _path) = workspace(); + std::fs::write(temp.path().join(".gitignore"), "ignored.rs\n").unwrap(); + let shared = Arc::new(Mutex::new(workspace)); + let initial_generation = shared.lock().unwrap().generation(); + let mut host = ProposalToolHost::new(Arc::clone(&shared)); + + for path in [".env", "ignored.rs", "../outside.rs", "private.key"] { + assert!( + host.read_file("session-1", path).await.is_err(), + "read disclosed {path}" + ); + assert!( + host.write_file("session-1", path, "unsafe\n".to_string()) + .await + .is_err(), + "write staged {path}" + ); + } + + assert_eq!(shared.lock().unwrap().generation(), initial_generation); + } + + #[test] + fn agent_overlays_prefer_session_proposals_and_filter_unsafe_buffers() { + let (temp, mut workspace, path) = workspace(); + std::fs::write(temp.path().join(".gitignore"), "ignored.rs\n").unwrap(); + workspace + .sync_visible_file(temp.path().join(".env"), 1, "TOKEN=secret\n".to_string()) + .unwrap(); + workspace + .sync_visible_file(temp.path().join("ignored.rs"), 1, "ignored\n".to_string()) + .unwrap(); + workspace + .write("session-1", &path, "one\nproposed\nthree\n".to_string()) + .unwrap(); + workspace + .write("other-session", &path, "one\nother\nthree\n".to_string()) + .unwrap(); + + let overlays = workspace.agent_overlay_files("session-1").unwrap(); + + assert_eq!( + overlays, + vec![("src.rs".to_string(), "one\nproposed\nthree\n".to_string())] + ); + } + #[test] fn editor_range_edits_are_atomic_unicode_aware_and_revision_checked() { let (temp, mut workspace, path) = workspace(); diff --git a/src/codex/mod.rs b/src/codex/mod.rs index 67b951c8..578a031a 100644 --- a/src/codex/mod.rs +++ b/src/codex/mod.rs @@ -1,8 +1,8 @@ //! Direct client for the installed Codex app-server. //! -//! Red deliberately runs Codex read-only and exposes bounded dynamic tools for -//! editor-aware reads and reviewable proposal writes. No ACP adapter sits -//! between the editor and Codex. +//! Red runs the user's installed Codex app-server directly and supplements its +//! native capabilities with bounded, editor-aware dynamic tools. An explicitly +//! selected review-safe profile retains isolated, proposal-backed edits. use std::{ collections::HashMap, @@ -32,7 +32,9 @@ use tokio::{ time::timeout, }; -use crate::agent_tools::{editor_tool_schemas, EditorToolCall, EditorToolRequest}; +use crate::agent_tools::{ + editor_tool_schemas, ensure_agent_path_disclosable, EditorToolCall, EditorToolRequest, +}; const APP_FRAME_BYTES: usize = 1024 * 1024; const TOOL_CONTENT_BYTES: usize = 960 * 1024; @@ -46,7 +48,20 @@ const MAX_WALK_ENTRIES: usize = 65_536; const MAX_WALK_TIME: Duration = Duration::from_secs(5); const SETUP_TIMEOUT: Duration = Duration::from_secs(30); const TOOL_TIMEOUT: Duration = Duration::from_secs(30); -const INSTRUCTIONS: &str = "You are Red's coding assistant. You have no shell or native patch tool. Use list_files and search_files to locate relevant code. Use get_editor_state, open_file, select_text, and run_editor_action to inspect and navigate the editor. Always use read_file before reasoning about a file, and use apply_edits or write_file for every edit. Edits are reviewable editor proposals and never touch disk. Do not claim a change was saved. Keep responses concise."; +const MAX_PENDING_APPROVALS: usize = 32; +const MAX_MODEL_BYTES: usize = 256; +const MAX_REASONING_EFFORT_BYTES: usize = 64; +const REVIEW_SAFE_INSTRUCTIONS: &str = "You are Red's coding assistant. You have no shell or native patch tool. Use list_files and search_files to locate relevant code. Use get_editor_state, open_file, select_text, and run_editor_action to inspect and navigate the editor. Always use read_file before reasoning about a file, and use apply_edits or write_file for every edit. Edits are reviewable editor proposals and never touch disk. Do not claim a change was saved. Keep responses concise."; + +/// Capability profile selected for an installed Codex app-server. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CodexExecutionMode { + /// Preserve the user's actual Codex configuration, tools, and approvals. + #[default] + Native, + /// Disable native writes and run solely through Red's proposal workspace. + ReviewSafe, +} /// Exact process launch specification for one Codex app-server worker. #[derive(Debug, Clone)] @@ -59,6 +74,16 @@ pub struct CodexProcessSpec { pub environment: HashMap, /// Working directory used for process launch and thread configuration. pub current_dir: PathBuf, + /// Whether Codex uses native capabilities or isolated review-safe tools. + pub execution_mode: CodexExecutionMode, + /// Whether newly created Codex threads survive app-server shutdown. + pub persistent_threads: bool, + /// Whether prompts request Codex's built-in planning collaboration mode. + pub plan_mode: bool, + /// Optional model override applied to newly created threads. + pub model: Option, + /// Optional reasoning effort applied when a new turn starts. + pub reasoning_effort: Option, } impl CodexProcessSpec { @@ -70,6 +95,11 @@ impl CodexProcessSpec { args: Vec::new(), environment: HashMap::new(), current_dir: current_dir.into(), + execution_mode: CodexExecutionMode::Native, + persistent_threads: true, + plan_mode: false, + model: None, + reasoning_effort: None, } } @@ -79,16 +109,65 @@ impl CodexProcessSpec { self.args.extend(args.into_iter().map(Into::into)); self } + + #[must_use] + /// Selects native Codex capabilities or Red's isolated review-safe profile. + pub fn execution_mode(mut self, mode: CodexExecutionMode) -> Self { + self.execution_mode = mode; + self + } + + #[must_use] + /// Controls whether newly created conversations are persisted by Codex. + pub fn persistent_threads(mut self, persistent: bool) -> Self { + self.persistent_threads = persistent; + self + } + + #[must_use] + /// Requests native planning once the server provides the effective model. + pub fn plan_mode(mut self, enabled: bool) -> Self { + self.plan_mode = enabled; + self + } + + #[must_use] + /// Selects a Codex model without overriding the remaining user configuration. + pub fn model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + + #[must_use] + /// Selects the reasoning effort advertised by the installed Codex model. + pub fn reasoning_effort(mut self, effort: impl Into) -> Self { + self.reasoning_effort = Some(effort.into()); + self + } } /// Commands sent from the editor owner to the Codex worker. #[derive(Debug, Clone)] pub enum CodexCommand { - /// Creates an ephemeral app-server thread for a workspace. + /// Creates an app-server thread for a workspace. NewSession { /// Physical workspace root. cwd: PathBuf, }, + /// Resumes a persisted app-server thread in its original workspace. + ResumeSession { + /// Persisted Codex thread identifier. + session_id: String, + /// Physical workspace root. + cwd: PathBuf, + }, + /// Recovers an automatically persisted thread or starts one fresh session. + RecoverSession { + /// Persisted Codex thread identifier to attempt exactly once. + session_id: String, + /// Physical workspace root used if the thread is no longer available. + cwd: PathBuf, + }, /// Submits plain user text to a session. Prompt { /// Red session identifier. @@ -107,6 +186,34 @@ pub enum CodexCommand { /// Bounded editor-provided context. context: String, }, + /// Adds input directly to an existing, steerable Codex turn. + Steer { + /// Active Codex thread identifier. + session_id: String, + /// Additional user instructions. + text: String, + }, + /// Requests models available to an active conversation. + ListModels { + /// Conversation receiving the model-catalog activity. + session_id: String, + }, + /// Selects the model and optional reasoning effort for subsequent turns. + SetModel { + /// Conversation whose future turns use the selected model. + session_id: String, + /// Exact model identifier returned by the Codex model catalog. + model: String, + /// Optional supported reasoning effort for the selected model. + reasoning_effort: Option, + }, + /// Requests durable conversations belonging to a workspace. + ListSessions { + /// Conversation receiving the workspace session-list activity. + session_id: String, + /// Physical workspace root used to filter Codex threads. + cwd: PathBuf, + }, /// Interrupts the active turn for a session. Cancel { /// Red session identifier. @@ -263,6 +370,10 @@ pub trait CodexToolHost: Send + 'static { async fn write_file(&mut self, session_id: &str, path: &str, content: String) -> Result; /// Dispatches an editor-owned semantic tool request. async fn editor_tool(&mut self, request: EditorToolRequest) -> Result; + /// Returns bounded visible and staged files that differ from disk. + async fn overlay_files(&mut self, _session_id: &str) -> Result> { + Ok(Vec::new()) + } } #[derive(Debug)] @@ -271,14 +382,61 @@ struct Session { active_turn: Option, cancelled: Arc, tool_calls: usize, + model: Option, + reasoning_effort: Option, + close_requested: bool, } enum Pending { - Config { cwd: PathBuf }, - Requirements { cwd: PathBuf, config: Value }, - Start { cwd: PathBuf }, - Turn { session_id: String }, - Interrupt { session_id: String }, + Config { + cwd: PathBuf, + }, + Requirements { + cwd: PathBuf, + config: Value, + }, + Start { + cwd: PathBuf, + }, + Resume { + cwd: PathBuf, + session_id: String, + recover: bool, + }, + Turn { + session_id: String, + }, + Interrupt { + session_id: String, + turn_id: String, + }, + Steer { + session_id: String, + }, + Models { + session_id: String, + }, + Sessions { + session_id: String, + }, +} + +#[derive(Debug, Clone)] +struct WorkerSettings { + execution_mode: CodexExecutionMode, + persistent_threads: bool, + plan_mode: bool, + model: Option, + reasoning_effort: Option, +} + +#[derive(Debug)] +struct PendingApproval { + id: Value, + session_id: String, + turn_id: String, + responses: HashMap, + declined: Value, } enum InternalEvent { @@ -286,6 +444,8 @@ enum InternalEvent { id: Value, session_id: String, turn_id: String, + tool: String, + title: String, result: std::result::Result, }, } @@ -307,18 +467,21 @@ async fn run( mut commands: mpsc::Receiver, events: mpsc::Sender, ) -> Result<()> { - let mut child = Command::new(&spec.command) - .arg("app-server") - .arg("--stdio") - .args(&spec.args) - .arg("-c") - .arg("features.apps=false") - .arg("-c") - .arg("features.connectors=false") - .arg("-c") - .arg("features.plugins=false") - .arg("-c") - .arg("features.remote_plugin=false") + let settings = WorkerSettings { + execution_mode: spec.execution_mode, + persistent_threads: spec.persistent_threads, + plan_mode: spec.plan_mode, + model: spec.model.clone(), + reasoning_effort: spec.reasoning_effort.clone(), + }; + let mut command = Command::new(&spec.command); + command.arg("app-server").arg("--stdio").args(&spec.args); + if settings.execution_mode == CodexExecutionMode::ReviewSafe { + for feature in ["apps", "connectors", "plugins", "remote_plugin"] { + command.arg("-c").arg(format!("features.{feature}=false")); + } + } + let mut child = command .envs(&spec.environment) .current_dir(&spec.current_dir) .stdin(Stdio::piped()) @@ -389,6 +552,7 @@ async fn run( let mut next_id = 1_u64; let mut pending = HashMap::::new(); let mut sessions = HashMap::::new(); + let mut approvals = HashMap::::new(); loop { tokio::select! { @@ -400,7 +564,9 @@ async fn run( &events, &mut pending, &mut sessions, + &mut approvals, &mut next_id, + &settings, ).await?; } message = lines_rx.recv() => { @@ -413,13 +579,22 @@ async fn run( &events, &mut pending, &mut sessions, + &mut approvals, &mut next_id, Arc::clone(&host), internal_tx.clone(), + &settings, ).await?; } internal = internal_rx.recv() => { - let Some(InternalEvent::ToolResult { id, session_id, turn_id, result }) = internal else { + let Some(InternalEvent::ToolResult { + id, + session_id, + turn_id, + tool, + title, + result, + }) = internal else { continue; }; let active = sessions.get(&session_id).is_some_and(|session| { @@ -431,6 +606,28 @@ async fn run( } else { Err("Codex tool references an inactive turn".to_string()) }; + if active { + let status = if result.is_ok() { "completed" } else { "failed" }; + send_activity( + &events, + &session_id, + json!({ + "session_update": "tool_call_update", + "tool_call_id": id_key(&id), + "title": title, + "kind": tool, + "status": status, + }), + ) + .await; + if result.is_ok() && matches!(tool.as_str(), "write_file" | "apply_edits") { + let _ = events + .send(CodexEvent::ProposalsChanged { + session_id: session_id.clone(), + }) + .await; + } + } send_tool_result(&mut input, id, result).await?; } } @@ -441,30 +638,32 @@ async fn run( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn handle_command( command: CodexCommand, input: &mut (impl AsyncWrite + Unpin), events: &mpsc::Sender, pending: &mut HashMap, sessions: &mut HashMap, + approvals: &mut HashMap, next_id: &mut u64, + settings: &WorkerSettings, ) -> Result<()> { match command { CodexCommand::NewSession { cwd } => { - let id = rpc_id(next_id); - pending.insert(id.clone(), Pending::Config { cwd: cwd.clone() }); - write_message( - input, - &json!({ - "id": id, - "method": "config/read", - "params": {"includeLayers": false, "cwd": cwd} - }), - ) - .await?; + request_session_config(cwd, input, pending, next_id).await?; + } + CodexCommand::ResumeSession { session_id, cwd } => { + request_session_resume(session_id, cwd, false, input, pending, next_id).await?; + } + CodexCommand::RecoverSession { session_id, cwd } => { + request_session_resume(session_id, cwd, true, input, pending, next_id).await?; } CodexCommand::Prompt { session_id, text } => { - start_turn(session_id, text, input, events, pending, sessions, next_id).await?; + start_turn( + session_id, text, input, events, pending, sessions, next_id, settings, + ) + .await?; } CodexCommand::PromptWithContext { session_id, @@ -474,19 +673,157 @@ async fn handle_command( } => { let text = format!("{text}\n\nActive editor context from {uri}:\n\n```text\n{context}\n```"); - start_turn(session_id, text, input, events, pending, sessions, next_id).await?; + start_turn( + session_id, text, input, events, pending, sessions, next_id, settings, + ) + .await?; + } + CodexCommand::Steer { session_id, text } => { + steer_turn(session_id, text, input, events, pending, sessions, next_id).await?; + } + CodexCommand::ListModels { session_id } => { + let id = rpc_id(next_id); + pending.insert(id.clone(), Pending::Models { session_id }); + write_message( + input, + &json!({"id": id, "method": "model/list", "params": {}}), + ) + .await?; + } + CodexCommand::SetModel { + session_id, + model, + reasoning_effort, + } => { + let valid_model = !model.trim().is_empty() + && model.len() <= MAX_MODEL_BYTES + && !model.chars().any(char::is_control); + let valid_effort = reasoning_effort.as_deref().is_none_or(|effort| { + !effort.trim().is_empty() + && effort.len() <= MAX_REASONING_EFFORT_BYTES + && !effort.chars().any(char::is_control) + }); + if !valid_model || !valid_effort { + let _ = events + .send(CodexEvent::Failed { + session_id: Some(session_id), + message: "Codex model or reasoning effort is invalid".to_string(), + }) + .await; + return Ok(()); + } + let Some(session) = sessions.get_mut(&session_id) else { + let _ = events + .send(CodexEvent::Failed { + session_id: Some(session_id), + message: "Codex session was not found".to_string(), + }) + .await; + return Ok(()); + }; + session.model = Some(model.clone()); + session.reasoning_effort.clone_from(&reasoning_effort); + send_activity( + events, + &session_id, + json!({ + "session_update": "model_selected", + "model": model, + "reasoning_effort": reasoning_effort, + }), + ) + .await; + } + CodexCommand::ListSessions { session_id, cwd } => { + let id = rpc_id(next_id); + pending.insert(id.clone(), Pending::Sessions { session_id }); + write_message( + input, + &json!({ + "id": id, + "method": "thread/list", + "params": {"cwd": cwd, "archived": false, "limit": 50}, + }), + ) + .await?; } CodexCommand::Cancel { session_id } => { - stop_session(session_id, false, input, events, pending, sessions, next_id).await?; + stop_session( + session_id, false, input, events, pending, sessions, approvals, next_id, + ) + .await?; } CodexCommand::CloseSession { session_id } => { - stop_session(session_id, true, input, events, pending, sessions, next_id).await?; + stop_session( + session_id, true, input, events, pending, sessions, approvals, next_id, + ) + .await?; + } + CodexCommand::PermissionResponse { + request_id, + option_id, + } => { + if let Some(approval) = approvals.remove(&request_id) { + let response = option_id + .as_deref() + .and_then(|option| approval.responses.get(option)) + .cloned() + .unwrap_or(approval.declined); + write_message(input, &json!({"id": approval.id, "result": response})).await?; + } } - CodexCommand::PermissionResponse { .. } => {} } Ok(()) } +async fn request_session_config( + cwd: PathBuf, + input: &mut (impl AsyncWrite + Unpin), + pending: &mut HashMap, + next_id: &mut u64, +) -> Result<()> { + let id = rpc_id(next_id); + pending.insert(id.clone(), Pending::Config { cwd: cwd.clone() }); + write_message( + input, + &json!({ + "id": id, + "method": "config/read", + "params": {"includeLayers": false, "cwd": cwd} + }), + ) + .await +} + +async fn request_session_resume( + session_id: String, + cwd: PathBuf, + recover: bool, + input: &mut (impl AsyncWrite + Unpin), + pending: &mut HashMap, + next_id: &mut u64, +) -> Result<()> { + let id = rpc_id(next_id); + pending.insert( + id.clone(), + Pending::Resume { + cwd: cwd.clone(), + session_id: session_id.clone(), + recover, + }, + ); + write_message( + input, + &json!({ + "id": id, + "method": "thread/resume", + "params": {"threadId": session_id, "cwd": cwd}, + }), + ) + .await +} + +#[allow(clippy::too_many_arguments)] async fn start_turn( session_id: String, text: String, @@ -495,6 +832,7 @@ async fn start_turn( pending: &mut HashMap, sessions: &mut HashMap, next_id: &mut u64, + settings: &WorkerSettings, ) -> Result<()> { let Some(session) = sessions.get_mut(&session_id) else { events @@ -518,25 +856,93 @@ async fn start_turn( session_id: session_id.clone(), }, ); + let mut params = json!({ + "threadId": session_id, + "input": [{"type": "text", "text": text}], + }); + if settings.execution_mode == CodexExecutionMode::ReviewSafe { + params["approvalPolicy"] = json!("never"); + params["sandboxPolicy"] = json!({"type": "readOnly"}); + params["environments"] = json!([]); + } + if let Some(model) = session.model.as_ref().or(settings.model.as_ref()) { + params["model"] = json!(model); + } + if let Some(effort) = session + .reasoning_effort + .as_ref() + .or(settings.reasoning_effort.as_ref()) + { + params["effort"] = json!(effort); + } + if settings.plan_mode { + if let Some(model) = session.model.as_ref().or(settings.model.as_ref()) { + params["collaborationMode"] = json!({ + "mode": "plan", + "settings": { + "model": model, + "reasoning_effort": session + .reasoning_effort + .as_deref() + .or(settings.reasoning_effort.as_deref()) + .unwrap_or("medium"), + "developer_instructions": null, + }, + }); + } + } + write_message( + input, + &json!({"id": id, "method": "turn/start", "params": params}), + ) + .await +} + +async fn steer_turn( + session_id: String, + text: String, + input: &mut (impl AsyncWrite + Unpin), + events: &mpsc::Sender, + pending: &mut HashMap, + sessions: &HashMap, + next_id: &mut u64, +) -> Result<()> { + let Some(turn_id) = sessions + .get(&session_id) + .filter(|session| !session.cancelled.load(Ordering::Relaxed)) + .and_then(|session| session.active_turn.as_deref()) + else { + let _ = events + .send(CodexEvent::Failed { + session_id: Some(session_id), + message: "there is no active Codex turn to steer".to_string(), + }) + .await; + return Ok(()); + }; + let id = rpc_id(next_id); + pending.insert( + id.clone(), + Pending::Steer { + session_id: session_id.clone(), + }, + ); write_message( input, &json!({ "id": id, - "method": "turn/start", + "method": "turn/steer", "params": { "threadId": session_id, + "expectedTurnId": turn_id, "input": [{"type": "text", "text": text}], - "approvalPolicy": "never", - "sandboxPolicy": { - "type": "readOnly" - }, - "environments": [] - } + }, }), ) .await } +#[allow(clippy::too_many_arguments)] async fn stop_session( session_id: String, close: bool, @@ -544,18 +950,23 @@ async fn stop_session( events: &mpsc::Sender, pending: &mut HashMap, sessions: &mut HashMap, + approvals: &mut HashMap, next_id: &mut u64, ) -> Result<()> { let turn_id = sessions.get_mut(&session_id).and_then(|session| { session.cancelled.store(true, Ordering::Relaxed); - session.active_turn.take() + session.close_requested |= close; + session.active_turn.clone() }); + let has_active_turn = turn_id.is_some(); + reject_session_approvals(input, approvals, &session_id).await?; if let Some(turn_id) = turn_id { let id = rpc_id(next_id); pending.insert( id.clone(), Pending::Interrupt { session_id: session_id.clone(), + turn_id: turn_id.clone(), }, ); write_message( @@ -575,7 +986,7 @@ async fn stop_session( .await .ok(); } - if close { + if close && !has_active_turn { sessions.remove(&session_id); } Ok(()) @@ -588,18 +999,41 @@ async fn handle_message( events: &mpsc::Sender, pending: &mut HashMap, sessions: &mut HashMap, + approvals: &mut HashMap, next_id: &mut u64, host: Arc>, internal: mpsc::Sender, + settings: &WorkerSettings, ) -> Result<()> { if message.get("method").is_none() { - return handle_response(message, input, events, pending, sessions, next_id).await; + return handle_response(message, input, events, pending, sessions, next_id, settings).await; } let method = message .get("method") .and_then(Value::as_str) .unwrap_or_default(); match method { + "turn/started" => { + let params = &message["params"]; + let session_id = params["threadId"].as_str().unwrap_or_default(); + let turn_id = params + .pointer("/turn/id") + .and_then(Value::as_str) + .unwrap_or_default(); + if let Some(session) = sessions.get_mut(session_id) { + if session.active_turn.is_none() && !turn_id.is_empty() { + session.active_turn = Some(turn_id.to_string()); + } + if session.active_turn.as_deref() == Some(turn_id) { + send_activity( + events, + session_id, + json!({"session_update": "turn_started", "turn_id": turn_id}), + ) + .await; + } + } + } "item/agentMessage/delta" => { let params = &message["params"]; let session_id = params["threadId"].as_str().unwrap_or_default(); @@ -620,6 +1054,77 @@ async fn handle_message( .ok(); } } + "item/started" | "item/completed" => { + forward_item_activity(events, sessions, method, &message["params"]).await; + } + "item/reasoning/summaryTextDelta" => { + let params = &message["params"]; + let session_id = params["threadId"].as_str().unwrap_or_default(); + let turn_id = params["turnId"].as_str().unwrap_or_default(); + let delta = params["delta"].as_str().unwrap_or_default(); + if !delta.is_empty() && session_accepts_activity(sessions, session_id, turn_id) { + send_activity( + events, + session_id, + json!({ + "session_update": "agent_thought_chunk", + "content": {"type": "text", "text": delta}, + "item_id": params["itemId"], + }), + ) + .await; + } + } + "turn/plan/updated" => { + let params = &message["params"]; + let session_id = params["threadId"].as_str().unwrap_or_default(); + let turn_id = params["turnId"].as_str().unwrap_or_default(); + if session_accepts_activity(sessions, session_id, turn_id) { + send_activity( + events, + session_id, + json!({ + "session_update": "plan", + "turn_id": turn_id, + "explanation": params["explanation"], + "plan": params["plan"], + }), + ) + .await; + } + } + "thread/tokenUsage/updated" => { + let params = &message["params"]; + let session_id = params["threadId"].as_str().unwrap_or_default(); + if sessions.contains_key(session_id) { + send_activity( + events, + session_id, + json!({ + "session_update": "token_usage", + "token_usage": params["tokenUsage"], + }), + ) + .await; + } + } + "error" => { + let params = &message["params"]; + let session_id = params["threadId"].as_str().unwrap_or_default(); + let turn_id = params["turnId"].as_str().unwrap_or_default(); + if session_accepts_activity(sessions, session_id, turn_id) { + send_activity( + events, + session_id, + json!({ + "session_update": "error", + "error": params["error"], + "will_retry": params["willRetry"], + }), + ) + .await; + } + } "turn/completed" => { let params = &message["params"]; let session_id = params["threadId"].as_str().unwrap_or_default().to_string(); @@ -628,37 +1133,53 @@ async fn handle_message( .as_str() .unwrap_or("completed") .to_string(); - if let Some(session) = sessions.get_mut(&session_id) { - if session.active_turn.as_deref() == Some(turn_id) { - session.active_turn = None; - events - .send(CodexEvent::Completed { - session_id, - stop_reason: status, - }) - .await - .ok(); - } + let Some(session) = sessions.get_mut(&session_id) else { + return Ok(()); + }; + if session.active_turn.as_deref() != Some(turn_id) { + return Ok(()); } - } - "item/tool/call" => { - handle_tool_call(message, input, sessions, host, internal).await?; - } - "item/fileChange/requestApproval" | "item/commandExecution/requestApproval" => { - if let Some(id) = message.get("id") { - write_message(input, &json!({"id": id, "result": {"decision": "decline"}})).await?; + session.active_turn = None; + session.cancelled.store(false, Ordering::Relaxed); + let close_requested = session.close_requested; + approvals.retain(|_, approval| { + approval.session_id != session_id || approval.turn_id != turn_id + }); + if status == "failed" { + let message = params + .pointer("/turn/error/message") + .and_then(Value::as_str) + .unwrap_or("Codex turn failed") + .to_string(); + let _ = events + .send(CodexEvent::Failed { + session_id: Some(session_id.clone()), + message, + }) + .await; + } else { + let _ = events + .send(CodexEvent::Completed { + session_id: session_id.clone(), + stop_reason: status, + }) + .await; + } + if close_requested { + sessions.remove(&session_id); } } - "item/permissions/requestApproval" => { - if let Some(id) = message.get("id") { - write_message( - input, - &json!({ - "id": id, - "result": {"permissions": {}, "scope": "turn", "strictAutoReview": true} - }), - ) - .await?; + "item/tool/call" => { + handle_tool_call(message, input, events, sessions, host, internal).await?; + } + "item/fileChange/requestApproval" + | "item/commandExecution/requestApproval" + | "item/permissions/requestApproval" => { + handle_approval_request(message, input, events, sessions, approvals, settings).await?; + } + "serverRequest/resolved" => { + if let Some(id) = message.pointer("/params/requestId") { + approvals.remove(&id_key(id)); } } _ if message.get("id").is_some() => { @@ -676,6 +1197,312 @@ async fn handle_message( Ok(()) } +fn session_accepts_activity( + sessions: &HashMap, + session_id: &str, + turn_id: &str, +) -> bool { + !session_id.is_empty() + && !turn_id.is_empty() + && sessions.get(session_id).is_some_and(|session| { + session.active_turn.as_deref() == Some(turn_id) + && !session.cancelled.load(Ordering::Relaxed) + }) +} + +async fn send_activity(events: &mpsc::Sender, session_id: &str, update: Value) { + if session_id.is_empty() { + return; + } + let _ = events + .send(CodexEvent::Activity { + session_id: session_id.to_string(), + update, + }) + .await; +} + +async fn forward_item_activity( + events: &mpsc::Sender, + sessions: &HashMap, + method: &str, + params: &Value, +) { + let session_id = params["threadId"].as_str().unwrap_or_default(); + let turn_id = params["turnId"].as_str().unwrap_or_default(); + if !session_accepts_activity(sessions, session_id, turn_id) { + return; + } + let item = ¶ms["item"]; + let kind = item["type"].as_str().unwrap_or_default(); + if matches!(kind, "agentMessage" | "userMessage" | "reasoning") { + return; + } + let default_status = if method == "item/completed" { + "completed" + } else { + "in_progress" + }; + let status = item["status"] + .as_str() + .map(normalized_item_status) + .unwrap_or(default_status); + let title = item_activity_title(kind, item); + let session_update = if method == "item/completed" { + "tool_call_update" + } else { + "tool_call" + }; + send_activity( + events, + session_id, + json!({ + "session_update": session_update, + "tool_call_id": item["id"], + "title": title, + "kind": kind, + "status": status, + "item": item, + }), + ) + .await; +} + +fn normalized_item_status(status: &str) -> &str { + match status { + "inProgress" => "in_progress", + "notStarted" => "pending", + other => other, + } +} + +fn item_activity_title(kind: &str, item: &Value) -> String { + let title = match kind { + "commandExecution" => item["command"] + .as_str() + .map(|command| format!("Running {command}")) + .unwrap_or_else(|| "Running a command".to_string()), + "fileChange" => { + let paths = item["changes"] + .as_array() + .into_iter() + .flatten() + .filter_map(|change| change["path"].as_str()) + .take(3) + .collect::>(); + if paths.is_empty() { + "Updating workspace files".to_string() + } else { + format!("Updating {}", paths.join(", ")) + } + } + "mcpToolCall" => { + let server = item["server"].as_str().unwrap_or("MCP"); + let tool = item["tool"].as_str().unwrap_or("tool"); + format!("Calling {server}: {tool}") + } + "dynamicToolCall" => item["tool"] + .as_str() + .map(|tool| format!("Using {tool}")) + .unwrap_or_else(|| "Using an editor tool".to_string()), + "webSearch" => item["query"] + .as_str() + .map(|query| format!("Searching for {query}")) + .unwrap_or_else(|| "Searching the web".to_string()), + "plan" => "Preparing a plan".to_string(), + "contextCompaction" => "Compacting conversation context".to_string(), + "enteredReviewMode" => "Reviewing changes".to_string(), + "exitedReviewMode" => "Review completed".to_string(), + "collabToolCall" => "Coordinating an agent".to_string(), + _ => item["title"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| "Working".to_string()), + }; + title.chars().take(240).collect() +} + +async fn reject_session_approvals( + input: &mut (impl AsyncWrite + Unpin), + approvals: &mut HashMap, + session_id: &str, +) -> Result<()> { + let request_ids = approvals + .iter() + .filter(|(_, approval)| approval.session_id == session_id) + .map(|(id, _)| id.clone()) + .collect::>(); + for request_id in request_ids { + if let Some(approval) = approvals.remove(&request_id) { + write_message( + input, + &json!({"id": approval.id, "result": approval.declined}), + ) + .await?; + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_approval_request( + message: Value, + input: &mut (impl AsyncWrite + Unpin), + events: &mpsc::Sender, + sessions: &HashMap, + approvals: &mut HashMap, + settings: &WorkerSettings, +) -> Result<()> { + let Some(id) = message.get("id").cloned() else { + return Ok(()); + }; + let params = &message["params"]; + let session_id = params["threadId"].as_str().unwrap_or_default(); + let turn_id = params["turnId"].as_str().unwrap_or_default(); + let method = message["method"].as_str().unwrap_or_default(); + let is_permission = method == "item/permissions/requestApproval"; + let declined = if is_permission { + json!({"permissions": {}, "scope": "turn", "strictAutoReview": true}) + } else { + json!({"decision": "decline"}) + }; + if settings.execution_mode == CodexExecutionMode::ReviewSafe + || !session_accepts_activity(sessions, session_id, turn_id) + || approvals.len() >= MAX_PENDING_APPROVALS + { + return write_message(input, &json!({"id": id, "result": declined})).await; + } + + let (options, responses) = if is_permission { + permission_approval_options(params) + } else { + decision_approval_options(params) + }; + if options.is_empty() { + return write_message(input, &json!({"id": id, "result": declined})).await; + } + let request_id = id_key(&id); + if approvals.contains_key(&request_id) { + return write_message(input, &json!({"id": id, "result": declined})).await; + } + approvals.insert( + request_id.clone(), + PendingApproval { + id, + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + responses, + declined, + }, + ); + if events + .send(CodexEvent::PermissionRequested { + request_id: request_id.clone(), + session_id: session_id.to_string(), + tool_call: params.clone(), + options: Value::Array(options), + }) + .await + .is_err() + { + if let Some(approval) = approvals.remove(&request_id) { + write_message( + input, + &json!({"id": approval.id, "result": approval.declined}), + ) + .await?; + } + } + Ok(()) +} + +fn decision_approval_options(params: &Value) -> (Vec, HashMap) { + let decisions = params["availableDecisions"] + .as_array() + .cloned() + .unwrap_or_else(|| { + vec![ + json!("accept"), + json!("acceptForSession"), + json!("decline"), + json!("cancel"), + ] + }); + let mut options = Vec::with_capacity(decisions.len().min(8)); + let mut responses = HashMap::new(); + for decision in decisions.into_iter().take(8) { + let (option_id, label) = if let Some(value) = decision.as_str() { + let label = match value { + "accept" => "Allow once", + "acceptForSession" => "Allow for this session", + "decline" => "Decline", + "cancel" => "Cancel the turn", + _ => continue, + }; + (value.to_string(), label) + } else if let Some(object) = decision.as_object() { + if object.contains_key("acceptWithExecpolicyAmendment") { + ( + "acceptWithExecpolicyAmendment".to_string(), + "Allow and save the proposed command rule", + ) + } else if object.contains_key("applyNetworkPolicyAmendment") { + ( + "applyNetworkPolicyAmendment".to_string(), + "Apply the proposed network rule", + ) + } else { + continue; + } + } else { + continue; + }; + responses.insert(option_id.clone(), json!({"decision": decision})); + options.push(json!({ + "option_id": option_id, + "name": label, + "kind": "approval", + })); + } + (options, responses) +} + +fn permission_approval_options(params: &Value) -> (Vec, HashMap) { + let requested = params["permissions"].clone(); + if !requested.is_object() { + return (Vec::new(), HashMap::new()); + } + let choices = [ + ( + "accept", + "Allow for this turn", + json!({"permissions": requested, "scope": "turn"}), + ), + ( + "acceptForSession", + "Allow for this session", + json!({"permissions": requested, "scope": "session"}), + ), + ( + "decline", + "Decline", + json!({"permissions": {}, "scope": "turn", "strictAutoReview": true}), + ), + ]; + let mut options = Vec::with_capacity(choices.len()); + let mut responses = HashMap::with_capacity(choices.len()); + for (option_id, name, response) in choices { + options.push(json!({ + "option_id": option_id, + "name": name, + "kind": "permission", + })); + responses.insert(option_id.to_string(), response); + } + (options, responses) +} + +#[allow(clippy::too_many_arguments)] async fn handle_response( message: Value, input: &mut (impl AsyncWrite + Unpin), @@ -683,16 +1510,26 @@ async fn handle_response( pending: &mut HashMap, sessions: &mut HashMap, next_id: &mut u64, + settings: &WorkerSettings, ) -> Result<()> { let key = id_key(&message["id"]); let Some(request) = pending.remove(&key) else { return Ok(()); }; if let Some(error) = message.get("error") { + if let Pending::Resume { + cwd, recover: true, .. + } = &request + { + return request_session_config(cwd.clone(), input, pending, next_id).await; + } let session_id = match &request { - Pending::Turn { session_id } | Pending::Interrupt { session_id } => { - Some(session_id.clone()) - } + Pending::Turn { session_id } + | Pending::Interrupt { session_id, .. } + | Pending::Steer { session_id } + | Pending::Models { session_id } + | Pending::Sessions { session_id } + | Pending::Resume { session_id, .. } => Some(session_id.clone()), _ => None, }; events @@ -709,23 +1546,44 @@ async fn handle_response( } match request { Pending::Config { cwd } => { - let Some(config) = restricted_config(&message) else { - events - .send(CodexEvent::Failed { - session_id: None, - message: "Codex could not restrict configured tools".to_string(), - }) - .await - .ok(); - return Ok(()); - }; - let id = rpc_id(next_id); - pending.insert(id.clone(), Pending::Requirements { cwd, config }); - write_message( - input, - &json!({"id": id, "method": "configRequirements/read"}), - ) - .await?; + if settings.execution_mode == CodexExecutionMode::Native { + let id = rpc_id(next_id); + pending.insert(id.clone(), Pending::Start { cwd: cwd.clone() }); + let mut params = json!({ + "cwd": cwd, + "dynamicTools": tool_definitions(), + "serviceName": "red", + }); + if !settings.persistent_threads { + params["ephemeral"] = json!(true); + } + if let Some(model) = &settings.model { + params["model"] = json!(model); + } + write_message( + input, + &json!({"id": id, "method": "thread/start", "params": params}), + ) + .await?; + } else { + let Some(config) = restricted_config(&message) else { + events + .send(CodexEvent::Failed { + session_id: None, + message: "Codex could not restrict configured tools".to_string(), + }) + .await + .ok(); + return Ok(()); + }; + let id = rpc_id(next_id); + pending.insert(id.clone(), Pending::Requirements { cwd, config }); + write_message( + input, + &json!({"id": id, "method": "configRequirements/read"}), + ) + .await?; + } } Pending::Requirements { cwd, mut config } => { let Some(hooks_enabled) = required_hooks_mode(&message) else { @@ -742,27 +1600,36 @@ async fn handle_response( config["features"]["hooks"] = json!(hooks_enabled); let id = rpc_id(next_id); pending.insert(id.clone(), Pending::Start { cwd: cwd.clone() }); + let mut params = json!({ + "cwd": cwd, + "ephemeral": !settings.persistent_threads, + "approvalPolicy": "never", + "sandbox": "read-only", + "environments": [], + "config": config, + "dynamicTools": tool_definitions(), + "baseInstructions": REVIEW_SAFE_INSTRUCTIONS, + "serviceName": "red", + }); + if let Some(model) = &settings.model { + params["model"] = json!(model); + } write_message( input, - &json!({ - "id": id, - "method": "thread/start", - "params": { - "cwd": cwd, - "ephemeral": true, - "approvalPolicy": "never", - "sandbox": "read-only", - "environments": [], - "config": config, - "dynamicTools": tool_definitions(), - "baseInstructions": INSTRUCTIONS, - "serviceName": "red" - } - }), + &json!({"id": id, "method": "thread/start", "params": params}), ) .await?; } Pending::Start { cwd } => { + let model = message + .pointer("/result/model") + .and_then(Value::as_str) + .or_else(|| { + message + .pointer("/result/thread/model") + .and_then(Value::as_str) + }) + .map(str::to_string); let session_id = message .pointer("/result/thread/id") .and_then(Value::as_str) @@ -784,6 +1651,9 @@ async fn handle_response( active_turn: None, cancelled: Arc::new(AtomicBool::new(false)), tool_calls: 0, + model, + reasoning_effort: settings.reasoning_effort.clone(), + close_requested: false, }, ); events @@ -792,6 +1662,50 @@ async fn handle_response( .ok(); } } + Pending::Resume { + cwd, + session_id, + recover, + } => { + let returned_id = message + .pointer("/result/thread/id") + .and_then(Value::as_str) + .unwrap_or_default(); + if returned_id != session_id { + if recover { + return request_session_config(cwd, input, pending, next_id).await; + } + let _ = events + .send(CodexEvent::Failed { + session_id: Some(session_id), + message: "Codex returned an unexpected resumed thread".to_string(), + }) + .await; + } else { + let model = message + .pointer("/result/model") + .and_then(Value::as_str) + .or_else(|| { + message + .pointer("/result/thread/model") + .and_then(Value::as_str) + }) + .map(str::to_string); + sessions.insert( + session_id.clone(), + Session { + cwd, + active_turn: None, + cancelled: Arc::new(AtomicBool::new(false)), + tool_calls: 0, + model, + reasoning_effort: settings.reasoning_effort.clone(), + close_requested: false, + }, + ); + let _ = events.send(CodexEvent::SessionCreated { session_id }).await; + } + } Pending::Turn { session_id } => { let turn_id = message .pointer("/result/turn/id") @@ -799,11 +1713,79 @@ async fn handle_response( .unwrap_or_default() .to_string(); if let Some(session) = sessions.get_mut(&session_id) { - session.active_turn = Some(turn_id); + if turn_id.is_empty() { + let _ = events + .send(CodexEvent::Failed { + session_id: Some(session_id), + message: "Codex returned an invalid turn".to_string(), + }) + .await; + } else { + session.active_turn = Some(turn_id); + } + } + } + Pending::Interrupt { + session_id, + turn_id, + } => { + if sessions + .get(&session_id) + .is_some_and(|session| session.active_turn.as_deref() == Some(turn_id.as_str())) + { + let _ = events.send(CodexEvent::Cancelled { session_id }).await; + } + } + Pending::Steer { session_id } => { + let turn_id = message + .pointer("/result/turnId") + .and_then(Value::as_str) + .unwrap_or_default(); + if sessions + .get(&session_id) + .is_some_and(|session| session.active_turn.as_deref() == Some(turn_id)) + { + send_activity( + events, + &session_id, + json!({"session_update": "steer", "turn_id": turn_id}), + ) + .await; } } - Pending::Interrupt { session_id } => { - events.send(CodexEvent::Cancelled { session_id }).await.ok(); + Pending::Models { session_id } => { + let models = message + .pointer("/result/data") + .filter(|data| data.is_array()) + .cloned() + .unwrap_or_else(|| json!([])); + send_activity( + events, + &session_id, + json!({ + "session_update": "models", + "models": models, + "next_cursor": message.pointer("/result/nextCursor"), + }), + ) + .await; + } + Pending::Sessions { session_id } => { + let threads = message + .pointer("/result/data") + .filter(|data| data.is_array()) + .cloned() + .unwrap_or_else(|| json!([])); + send_activity( + events, + &session_id, + json!({ + "session_update": "sessions", + "sessions": threads, + "next_cursor": message.pointer("/result/nextCursor"), + }), + ) + .await; } } Ok(()) @@ -812,6 +1794,7 @@ async fn handle_response( async fn handle_tool_call( message: Value, input: &mut (impl AsyncWrite + Unpin), + events: &mpsc::Sender, sessions: &mut HashMap, host: Arc>, internal: mpsc::Sender, @@ -848,24 +1831,48 @@ async fn handle_tool_call( } let cwd = session.cwd.clone(); let cancelled = Arc::clone(&session.cancelled); + let title = dynamic_tool_activity_title(&tool, &arguments); + send_activity( + events, + &session_id, + json!({ + "session_update": "tool_call", + "tool_call_id": id_key(&id), + "title": title, + "kind": tool, + "status": "in_progress", + }), + ) + .await; + let dispatched_tool = tool.clone(); tokio::spawn(async move { let result = timeout(TOOL_TIMEOUT, async { - match tool.as_str() { - "list_files" => tokio::task::spawn_blocking(move || list_files(&cwd, &cancelled)) + match dispatched_tool.as_str() { + "list_files" => { + let overlays = host.lock().await.overlay_files(&session_id).await?; + tokio::task::spawn_blocking(move || { + list_files_with_overlays(&cwd, &cancelled, &overlays) + }) .await - .context("list_files task failed")?, + .context("list_files task failed")? + } "search_files" => { let query = required_string(&arguments, "query")?.to_string(); - tokio::task::spawn_blocking(move || search_files(&cwd, &query, &cancelled)) - .await - .context("search_files task failed")? + let overlays = host.lock().await.overlay_files(&session_id).await?; + tokio::task::spawn_blocking(move || { + search_files_with_overlays(&cwd, &query, &cancelled, &overlays) + }) + .await + .context("search_files task failed")? } "read_file" => { let path = required_string(&arguments, "path")?; + ensure_agent_path_disclosable(&cwd, Path::new(path))?; host.lock().await.read_file(&session_id, path).await } "write_file" => { let path = required_string(&arguments, "path")?; + ensure_agent_path_disclosable(&cwd, Path::new(path))?; let content = required_string(&arguments, "content")?.to_string(); host.lock() .await @@ -895,6 +1902,8 @@ async fn handle_tool_call( id, session_id, turn_id, + tool, + title, result, }) .await; @@ -902,8 +1911,65 @@ async fn handle_tool_call( Ok(()) } +fn dynamic_tool_activity_title(tool: &str, arguments: &Value) -> String { + let path = arguments["path"].as_str().unwrap_or_default(); + match tool { + "list_files" => "Listing workspace files".to_string(), + "search_files" => arguments["query"] + .as_str() + .map(|query| { + format!( + "Searching for {}", + query.chars().take(160).collect::() + ) + }) + .unwrap_or_else(|| "Searching workspace files".to_string()), + "read_file" => format!("Reading {path}"), + "write_file" => format!("Proposing changes to {path}"), + "get_editor_state" | "open_file" | "select_text" | "apply_edits" | "run_editor_action" => { + EditorToolCall::parse(tool, arguments.clone()) + .map_or_else(|_| format!("Using {tool}"), |call| call.activity_title()) + } + _ => format!("Using {tool}"), + } + .chars() + .take(240) + .collect() +} + +#[cfg(test)] fn list_files(root: &Path, cancelled: &AtomicBool) -> Result { - Ok(json!({"files": list_file_paths(root, cancelled)?})) + list_files_with_overlays(root, cancelled, &[]) +} + +fn list_files_with_overlays( + root: &Path, + cancelled: &AtomicBool, + overlays: &[(String, String)], +) -> Result { + Ok(json!({ + "files": file_paths_with_overlays(root, cancelled, overlays)? + })) +} + +fn file_paths_with_overlays( + root: &Path, + cancelled: &AtomicBool, + overlays: &[(String, String)], +) -> Result> { + let mut files = list_file_paths(root, cancelled)?; + for (relative, _) in overlays.iter().take(MAX_FILES) { + if cancelled.load(Ordering::Relaxed) { + anyhow::bail!("Codex turn was cancelled"); + } + if ensure_agent_path_disclosable(root, Path::new(relative)).is_ok() { + files.push(relative.clone()); + } + } + files.sort_unstable(); + files.dedup(); + files.truncate(MAX_FILES); + Ok(files) } fn list_file_paths(root: &Path, cancelled: &AtomicBool) -> Result> { @@ -925,6 +1991,9 @@ fn list_file_paths(root: &Path, cancelled: &AtomicBool) -> Result> { } let entry = entry?; if entry.file_type().is_some_and(|kind| kind.is_file()) { + if ensure_agent_path_disclosable(root, entry.path()).is_err() { + continue; + } if let Ok(path) = entry.path().strip_prefix(root) { files.push(path.to_string_lossy().replace('\\', "/")); if files.len() == MAX_FILES { @@ -937,28 +2006,53 @@ fn list_file_paths(root: &Path, cancelled: &AtomicBool) -> Result> { Ok(files) } +#[cfg(test)] fn search_files(root: &Path, query: &str, cancelled: &AtomicBool) -> Result { + search_files_with_overlays(root, query, cancelled, &[]) +} + +fn search_files_with_overlays( + root: &Path, + query: &str, + cancelled: &AtomicBool, + overlays: &[(String, String)], +) -> Result { anyhow::ensure!( !query.is_empty() && query.len() <= 1024, "invalid search query" ); #[cfg(not(unix))] { - let _ = (root, cancelled); + let _ = (root, cancelled, overlays); anyhow::bail!("workspace content search is unavailable on this platform"); } #[cfg(unix)] { - let files = list_file_paths(root, cancelled)?; + let files = file_paths_with_overlays(root, cancelled, overlays)?; + let overlays = overlays + .iter() + .filter(|(relative, content)| { + content.len() <= TOOL_CONTENT_BYTES + && ensure_agent_path_disclosable(root, Path::new(relative)).is_ok() + }) + .map(|(relative, content)| (relative.as_str(), content.as_str())) + .collect::>(); let mut matches = Vec::new(); let mut searched = 0_u64; for relative in files { if cancelled.load(Ordering::Relaxed) { anyhow::bail!("Codex turn was cancelled"); } - let Some((content, bytes)) = read_workspace_file(root, &relative)? else { - continue; + let disk_content; + let (content, bytes) = if let Some(content) = overlays.get(relative.as_str()) { + (*content, content.len() as u64) + } else { + let Some((content, bytes)) = read_workspace_file(root, &relative)? else { + continue; + }; + disk_content = content; + (disk_content.as_str(), bytes) }; searched = searched.saturating_add(bytes); if searched > MAX_SEARCH_BYTES { @@ -1358,6 +2452,153 @@ mod tests { assert_eq!(result["matches"][0]["path"], "inside.txt"); } + #[test] + fn workspace_discovery_excludes_sensitive_and_ignored_files() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join(".gitignore"), "ignored.txt\n").unwrap(); + std::fs::write(root.path().join("visible.txt"), "visible\n").unwrap(); + std::fs::write(root.path().join("ignored.txt"), "ignored\n").unwrap(); + std::fs::write(root.path().join(".env"), "TOKEN=secret\n").unwrap(); + std::fs::write(root.path().join("private.key"), "private\n").unwrap(); + + let result = list_files(root.path(), &AtomicBool::new(false)).unwrap(); + let files = result["files"].as_array().unwrap(); + + assert!(files.iter().any(|path| path == "visible.txt")); + for hidden in ["ignored.txt", ".env", "private.key"] { + assert!( + !files.iter().any(|path| path == hidden), + "disclosed {hidden}" + ); + } + } + + #[test] + fn discovery_includes_safe_visible_files_that_do_not_exist_on_disk() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join(".gitignore"), "ignored.rs\n").unwrap(); + let overlays = vec![ + ("draft.rs".to_string(), "unsaved draft\n".to_string()), + ("ignored.rs".to_string(), "ignored draft\n".to_string()), + (".env".to_string(), "TOKEN=secret\n".to_string()), + ("../outside.rs".to_string(), "outside\n".to_string()), + ]; + + let result = + list_files_with_overlays(root.path(), &AtomicBool::new(false), &overlays).unwrap(); + let files = result["files"].as_array().unwrap(); + + assert!(files.iter().any(|path| path == "draft.rs")); + for hidden in ["ignored.rs", ".env", "../outside.rs"] { + assert!( + !files.iter().any(|path| path == hidden), + "disclosed {hidden}" + ); + } + } + + #[test] + fn search_uses_visible_and_proposed_contents_instead_of_stale_disk() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("main.rs"), "old disk needle\n").unwrap(); + let overlays = vec![ + ( + "main.rs".to_string(), + "visible unsaved marker\n".to_string(), + ), + ("draft.rs".to_string(), "proposed marker\n".to_string()), + ]; + + let result = + search_files_with_overlays(root.path(), "marker", &AtomicBool::new(false), &overlays) + .unwrap(); + let matches = result["matches"].as_array().unwrap(); + + assert_eq!(matches.len(), 2); + assert!(matches + .iter() + .any(|entry| entry["path"] == "main.rs" && entry["text"] == "visible unsaved marker")); + assert!(matches + .iter() + .any(|entry| entry["path"] == "draft.rs" && entry["text"] == "proposed marker")); + + let stale = search_files_with_overlays( + root.path(), + "old disk needle", + &AtomicBool::new(false), + &overlays, + ) + .unwrap(); + assert!(stale["matches"].as_array().unwrap().is_empty()); + } + + #[test] + fn search_never_discloses_sensitive_or_ignored_overlay_contents() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join(".gitignore"), "ignored.rs\n").unwrap(); + let overlays = vec![ + (".env".to_string(), "hidden marker\n".to_string()), + ("ignored.rs".to_string(), "hidden marker\n".to_string()), + ("../outside.rs".to_string(), "hidden marker\n".to_string()), + ]; + + let result = + search_files_with_overlays(root.path(), "marker", &AtomicBool::new(false), &overlays) + .unwrap(); + + assert!(result["matches"].as_array().unwrap().is_empty()); + } + + #[test] + fn cancelled_overlay_discovery_and_search_stop_immediately() { + let root = tempfile::tempdir().unwrap(); + let overlays = vec![("draft.rs".to_string(), "marker\n".to_string())]; + let cancelled = AtomicBool::new(true); + + assert!(list_files_with_overlays(root.path(), &cancelled, &overlays) + .unwrap_err() + .to_string() + .contains("cancelled")); + assert!( + search_files_with_overlays(root.path(), "marker", &cancelled, &overlays) + .unwrap_err() + .to_string() + .contains("cancelled") + ); + } + + #[test] + fn approval_options_preserve_only_explicitly_supported_server_decisions() { + let (options, responses) = decision_approval_options(&json!({ + "availableDecisions": ["accept", "decline", "unsupported"] + })); + + assert_eq!(options.len(), 2); + assert_eq!(responses["accept"], json!({"decision": "accept"})); + assert_eq!(responses["decline"], json!({"decision": "decline"})); + assert!(!responses.contains_key("unsupported")); + } + + #[test] + fn permission_approval_options_keep_turn_and_session_scopes_explicit() { + let requested = json!({"network": {"enabled": true}}); + let (options, responses) = permission_approval_options(&json!({"permissions": requested})); + + assert_eq!(options.len(), 3); + assert_eq!( + responses["accept"], + json!({"permissions": {"network": {"enabled": true}}, "scope": "turn"}) + ); + assert_eq!( + responses["acceptForSession"], + json!({"permissions": {"network": {"enabled": true}}, "scope": "session"}) + ); + assert_eq!( + responses["decline"], + json!({"permissions": {}, "scope": "turn", "strictAutoReview": true}) + ); + } + #[test] fn search_rejects_a_symlinked_workspace_root() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/config.rs b/src/config.rs index 813c0761..37d74173 100644 --- a/src/config.rs +++ b/src/config.rs @@ -235,8 +235,55 @@ pub struct Config { pub startup_file_count: usize, } -/// Direct Codex CLI launch configuration. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +/// Native Codex capability profile used for new agent conversations. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum AgentMode { + /// Use the installed Codex configuration, native tools, and approval policy. + #[default] + Agent, + /// Ask Codex to prepare a plan before making changes. + Plan, + /// Keep all workspace edits isolated as explicitly reviewable proposals. + Review, +} + +/// Edge on which the persistent agent conversation is displayed. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum AgentPosition { + /// Reserve a vertical region on the left of the editor. + Left, + /// Reserve a vertical region on the right of the editor. + #[default] + Right, + /// Reserve a horizontal region above the editor. + Top, + /// Reserve a horizontal region below the editor. + Bottom, +} + +/// Surface used to collect the first agent prompt. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum AgentEntry { + /// Open a floating prompt before revealing the persistent conversation. + #[default] + Float, + /// Focus the persistent conversation composer immediately. + Dock, +} + +const fn default_agent_width_percent() -> u8 { + 38 +} + +const fn default_agent_height_percent() -> u8 { + 35 +} + +/// Direct Codex CLI launch, conversation, and user-interface configuration. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct AgentConfig { /// Codex executable override. Red uses `codex` from PATH when absent. @@ -247,6 +294,52 @@ pub struct AgentConfig { /// Environment additions supplied only to the Codex child process. #[serde(default)] pub env: HashMap, + /// Native agent, planning, or isolated review capability profile. + #[serde(default)] + pub mode: AgentMode, + /// Optional Codex model override; absent uses Codex's configured default. + #[serde(default)] + pub model: Option, + /// Optional reasoning effort advertised by the selected Codex model. + #[serde(default)] + pub reasoning_effort: Option, + /// Whether the first prompt opens in a floating or docked composer. + #[serde(default)] + pub entry: AgentEntry, + /// Preferred edge of the persistent agent conversation. + #[serde(default)] + pub position: AgentPosition, + /// Percentage of terminal width requested by left and right docks. + #[serde(default = "default_agent_width_percent")] + pub width_percent: u8, + /// Percentage of terminal height requested by top and bottom docks. + #[serde(default = "default_agent_height_percent")] + pub height_percent: u8, + /// Fall back to a horizontal dock when a side dock would crowd the editor. + #[serde(default = "default_true")] + pub responsive: bool, + /// Create durable Codex threads that can be resumed after editor restart. + #[serde(default = "default_true")] + pub persistent_threads: bool, +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + command: None, + args: Vec::new(), + env: HashMap::new(), + mode: AgentMode::Agent, + model: None, + reasoning_effort: None, + entry: AgentEntry::Float, + position: AgentPosition::Right, + width_percent: default_agent_width_percent(), + height_percent: default_agent_height_percent(), + responsive: true, + persistent_threads: true, + } + } } #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] @@ -1335,7 +1428,22 @@ fn known_schema_path(path: &[String]) -> bool { ["keys", ..] | ["plugin_config", ..] => true, ["plugins", _] => true, ["plugin_permissions", _] | ["plugin_permissions", _, "process"] => true, - ["agent", field] => matches!(*field, "adapter" | "command" | "args" | "env"), + ["agent", field] => matches!( + *field, + "adapter" + | "command" + | "args" + | "env" + | "mode" + | "model" + | "reasoning_effort" + | "entry" + | "position" + | "width_percent" + | "height_percent" + | "responsive" + | "persistent_threads" + ), ["agent", "env", _] => true, ["cursor", field] => matches!( *field, @@ -2483,6 +2591,72 @@ env = { NO_BROWSER = "1" } config.agent.env.get("NO_BROWSER").map(String::as_str), Some("1") ); + assert_eq!(config.agent.mode, AgentMode::Agent); + assert_eq!(config.agent.model, None); + assert_eq!(config.agent.reasoning_effort, None); + assert_eq!(config.agent.entry, AgentEntry::Float); + assert_eq!(config.agent.position, AgentPosition::Right); + } + + #[test] + fn agent_defaults_use_native_codex_and_an_adaptive_right_dock() { + let config = Config::from_user_toml_with_overrides("", &[]).unwrap(); + + assert_eq!(config.agent.mode, AgentMode::Agent); + assert_eq!(config.agent.entry, AgentEntry::Float); + assert_eq!(config.agent.position, AgentPosition::Right); + assert_eq!(config.agent.width_percent, 38); + assert_eq!(config.agent.height_percent, 35); + assert!(config.agent.responsive); + assert!(config.agent.persistent_threads); + } + + #[test] + fn agent_configuration_supports_all_four_conversation_positions() { + for (value, expected) in [ + ("left", AgentPosition::Left), + ("right", AgentPosition::Right), + ("top", AgentPosition::Top), + ("bottom", AgentPosition::Bottom), + ] { + let config = Config::from_user_toml_with_overrides( + &format!("[agent]\nposition = \"{value}\"\n"), + &[], + ) + .unwrap(); + + assert_eq!(config.agent.position, expected); + } + } + + #[test] + fn agent_configuration_supports_isolated_review_and_docked_entry() { + let config = Config::from_user_toml_with_overrides( + r#" +[agent] +mode = "review" +model = "gpt-5.4" +reasoning_effort = "high" +entry = "dock" +position = "bottom" +width_percent = 45 +height_percent = 40 +responsive = false +persistent_threads = false +"#, + &[], + ) + .unwrap(); + + assert_eq!(config.agent.mode, AgentMode::Review); + assert_eq!(config.agent.model.as_deref(), Some("gpt-5.4")); + assert_eq!(config.agent.reasoning_effort.as_deref(), Some("high")); + assert_eq!(config.agent.entry, AgentEntry::Dock); + assert_eq!(config.agent.position, AgentPosition::Bottom); + assert_eq!(config.agent.width_percent, 45); + assert_eq!(config.agent.height_percent, 40); + assert!(!config.agent.responsive); + assert!(!config.agent.persistent_threads); } #[test] diff --git a/src/editor.rs b/src/editor.rs index e6950cb1..8af98cca 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -27,7 +27,6 @@ mod session_manager; use std::{ cmp::Ordering, collections::{HashMap, HashSet, VecDeque}, - ffi::OsStr, fs, io::{stdout, Write as _}, num::NonZeroUsize, @@ -73,11 +72,16 @@ use crate::{ }, buffer::{Buffer, BufferId, SearchMatch, SyntaxSelection}, clipboard::{ClipboardProvider, DisabledClipboardProvider, NativeClipboardProvider}, - codex::{start_codex, CodexBridge, CodexCommand, CodexEvent, CodexProcessSpec}, + codex::{ + start_codex, CodexBridge, CodexCommand, CodexEvent, CodexExecutionMode, CodexProcessSpec, + }, color::Color, command, command_palette, comment::CommentSyntax, - config::{Config, ConfigDiagnostic, ConfigDiagnosticSource, ConfigRecovery, KeyAction}, + config::{ + AgentEntry, AgentMode, AgentPosition, Config, ConfigDiagnostic, ConfigDiagnosticSource, + ConfigRecovery, KeyAction, + }, dispatcher::Dispatcher, highlighter::Highlighter, log, @@ -133,6 +137,10 @@ const MAX_HIGHLIGHT_SLICE_BYTES: usize = 512 * 1024; const MAX_PLUGIN_VIEWPORT_LINE_CHARS: usize = 64 * 1024; const MAX_DIRECTORY_LISTING_ENTRIES: usize = 160; const AGENT_BRIDGE_CAPACITY: usize = 64; +const MIN_AGENT_EDITOR_WIDTH: usize = 36; +const MIN_AGENT_EDITOR_HEIGHT: usize = 6; +const MIN_AGENT_PANEL_WIDTH: usize = 24; +const MIN_AGENT_PANEL_HEIGHT: usize = 5; const MACRO_MAX_REPLAY_DEPTH: usize = 20; const MACRO_MAX_REPLAY_EVENTS: usize = 10_000; const SESSION_SNAPSHOT_WARNING: &str = @@ -502,57 +510,15 @@ fn agent_event_payload(event: CodexEvent) -> (&'static str, Value) { } fn agent_context_path_is_sensitive(path: &Path) -> bool { - let Some(name) = path.file_name().and_then(OsStr::to_str) else { - return true; - }; - let name = name.to_ascii_lowercase(); - name == ".env" - || name.starts_with(".env.") - || name.contains("secret") - || name.contains("credential") - || matches!(name.as_str(), "id_rsa" | "id_ed25519") - || matches!( - path.extension() - .and_then(OsStr::to_str) - .map(|extension| extension.to_ascii_lowercase()) - .as_deref(), - Some("pem" | "key" | "p12" | "pfx") - ) + crate::agent_tools::agent_path_is_sensitive(path) } fn agent_context_path_is_ignored(path: &Path, root: &Path) -> bool { - let mut ignored = false; - let mut directories = path - .parent() - .into_iter() - .flat_map(Path::ancestors) - .take_while(|directory| directory.starts_with(root)) - .collect::>(); - directories.reverse(); - for directory in directories { - for name in [".gitignore", ".ignore"] { - let (matcher, _) = ignore::gitignore::Gitignore::new(directory.join(name)); - match matcher.matched_path_or_any_parents(path, /*is_dir*/ false) { - ignore::Match::Ignore(_) => ignored = true, - ignore::Match::Whitelist(_) => ignored = false, - ignore::Match::None => {} - } - } - } - let mut builder = ignore::gitignore::GitignoreBuilder::new(root); - builder.add(root.join(".git/info/exclude")); - if let Ok(exclude) = builder.build() { - match exclude.matched_path_or_any_parents(path, /*is_dir*/ false) { - ignore::Match::Ignore(_) => ignored = true, - ignore::Match::Whitelist(_) => ignored = false, - ignore::Match::None => {} - } - } - ignored + crate::agent_tools::agent_path_is_ignored(path, root) } fn scoped_plugin_storage_key(plugin: &str, key: &str) -> String { - if plugin == "agent" && matches!(key, "transcript" | "prompt_history") { + if plugin == "agent" && matches!(key, "transcript" | "prompt_history" | "thread_id") { format!("{key}:{}", get_workspace_path().display()) } else { key.to_string() @@ -589,6 +555,10 @@ pub enum PluginRequest { AgentNewSession { cwd: PathBuf, }, + AgentResumeSession { + session_id: String, + cwd: PathBuf, + }, AgentPrompt { session_id: String, text: String, @@ -599,6 +569,25 @@ pub enum PluginRequest { uri: String, context: String, }, + AgentSteer { + session_id: String, + text: String, + }, + AgentListModels { + session_id: String, + }, + AgentListSessions { + session_id: String, + cwd: PathBuf, + }, + AgentSetModel { + session_id: String, + model: String, + reasoning_effort: Option, + }, + AgentSetReasoningEffort { + effort: String, + }, AgentCancel { session_id: String, }, @@ -887,6 +876,9 @@ pub enum PluginRequest { id: String, visible: bool, }, + SetAgentPosition { + position: AgentPosition, + }, ClosePanel { id: String, }, @@ -943,8 +935,14 @@ impl PluginRequest { match self { Self::Action(_) => "Action", Self::AgentNewSession { .. } => "AgentNewSession", + Self::AgentResumeSession { .. } => "AgentResumeSession", Self::AgentPrompt { .. } => "AgentPrompt", Self::AgentPromptWithContext { .. } => "AgentPromptWithContext", + Self::AgentSteer { .. } => "AgentSteer", + Self::AgentListModels { .. } => "AgentListModels", + Self::AgentListSessions { .. } => "AgentListSessions", + Self::AgentSetModel { .. } => "AgentSetModel", + Self::AgentSetReasoningEffort { .. } => "AgentSetReasoningEffort", Self::AgentCancel { .. } => "AgentCancel", Self::AgentCloseSession { .. } => "AgentCloseSession", Self::AgentArchiveSession { .. } => "AgentArchiveSession", @@ -1018,6 +1016,7 @@ impl PluginRequest { Self::FocusPanel { .. } => "FocusPanel", Self::FocusEditor => "FocusEditor", Self::SetPanelVisible { .. } => "SetPanelVisible", + Self::SetAgentPosition { .. } => "SetAgentPosition", Self::ClosePanel { .. } => "ClosePanel", Self::OpenWorkspace { .. } => "OpenWorkspace", Self::UpdateWorkspace { .. } => "UpdateWorkspace", @@ -3187,10 +3186,16 @@ impl Editor { fn focus_ring(&self) -> Vec { let mut targets = self .panel_manager - .focusable_ids_for_side(plugin::PanelSide::Left) + .focusable_ids_for_side(plugin::PanelSide::Top) .into_iter() .map(FocusTarget::Panel) .collect::>(); + targets.extend( + self.panel_manager + .focusable_ids_for_side(plugin::PanelSide::Left) + .into_iter() + .map(FocusTarget::Panel), + ); targets.extend( self.window_manager .windows() @@ -3203,6 +3208,12 @@ impl Editor { .into_iter() .map(FocusTarget::Panel), ); + targets.extend( + self.panel_manager + .focusable_ids_for_side(plugin::PanelSide::Bottom) + .into_iter() + .map(FocusTarget::Panel), + ); targets } @@ -3286,6 +3297,24 @@ impl Editor { } } + let side = match direction { + crate::window::Direction::Up => plugin::PanelSide::Top, + crate::window::Direction::Down => plugin::PanelSide::Bottom, + crate::window::Direction::Left => plugin::PanelSide::Left, + crate::window::Direction::Right => plugin::PanelSide::Right, + }; + if let Some(panel_id) = self + .panel_manager + .focusable_ids_for_side(side) + .into_iter() + .next() + { + if self.focus_target(&FocusTarget::Panel(panel_id)) { + self.render(buffer)?; + return Ok(()); + } + } + let message = match direction { crate::window::Direction::Up => "no window above", crate::window::Direction::Down => "no window below", @@ -3297,6 +3326,37 @@ impl Editor { Ok(()) } + fn move_focused_window_to_edge( + &mut self, + direction: crate::window::Direction, + buffer: &mut RenderBuffer, + ) -> anyhow::Result<()> { + if self.panel_manager.focused_panel_id() == Some("agent-conversation") + && !self.panel_manager.focused_row_panel() + { + let position = match direction { + crate::window::Direction::Up => AgentPosition::Top, + crate::window::Direction::Down => AgentPosition::Bottom, + crate::window::Direction::Left => AgentPosition::Left, + crate::window::Direction::Right => AgentPosition::Right, + }; + + if self.config.agent.position != position { + self.config.agent.position = position; + self.apply_panel_layout(); + self.render(buffer)?; + } + + return Ok(()); + } + + if self.update_window_layout(|windows| windows.move_window_to_edge(direction)) { + self.render(buffer)?; + } + + Ok(()) + } + fn update_window_layout( &mut self, update: impl FnOnce(&mut WindowManager) -> Option<()>, @@ -3313,14 +3373,18 @@ impl Editor { fn resize_window_layout(&mut self, terminal_size: (usize, usize)) { self.sync_to_window(); let (reserved_left, reserved_right) = self.reserved_panel_widths(terminal_size.0); + let (reserved_top, reserved_bottom) = self.reserved_panel_heights(terminal_size.1); self.window_manager.resize_with_origin( - Point::new(reserved_left, 0), + Point::new(reserved_left, reserved_top), ( terminal_size .0 .saturating_sub(reserved_left) .saturating_sub(reserved_right), - terminal_size.1, + terminal_size + .1 + .saturating_sub(reserved_top) + .saturating_sub(reserved_bottom), ), ); self.sync_with_window(); @@ -3328,6 +3392,7 @@ impl Editor { fn resize_terminal_surface(&mut self, width: u16, height: u16, buffer: &mut RenderBuffer) { self.size = (width, height); + self.refresh_agent_panel_layout(); let max_y = (height as usize).saturating_sub(2); self.cy = self.cy.min(max_y.saturating_sub(1)); self.resize_window_layout((width as usize, height as usize)); @@ -3346,15 +3411,19 @@ impl Editor { } fn apply_panel_layout(&mut self) { + self.refresh_agent_panel_layout(); self.sync_to_window(); let (reserved_left, reserved_right) = self.reserved_panel_widths(self.size.0 as usize); + let (reserved_top, reserved_bottom) = self.reserved_panel_heights(self.size.1 as usize); self.window_manager.resize_with_origin( - Point::new(reserved_left, 0), + Point::new(reserved_left, reserved_top), ( (self.size.0 as usize) .saturating_sub(reserved_left) .saturating_sub(reserved_right), - self.size.1 as usize, + (self.size.1 as usize) + .saturating_sub(reserved_top) + .saturating_sub(reserved_bottom), ), ); } @@ -3369,6 +3438,71 @@ impl Editor { (reserved_left, reserved_right) } + fn reserved_panel_heights(&self, terminal_height: usize) -> (usize, usize) { + let max_reserved = terminal_height.saturating_sub(MIN_AGENT_EDITOR_HEIGHT); + let reserved_top = self.panel_manager.reserved_top_height().min(max_reserved); + let reserved_bottom = self + .panel_manager + .reserved_bottom_height() + .min(max_reserved.saturating_sub(reserved_top)); + (reserved_top, reserved_bottom) + } + + fn configured_agent_panel_layout(&self) -> (plugin::PanelSide, usize) { + let terminal_width = usize::from(self.size.0); + let terminal_height = usize::from(self.size.1.saturating_sub(2)); + let requested_side = match self.config.agent.position { + AgentPosition::Left => plugin::PanelSide::Left, + AgentPosition::Right => plugin::PanelSide::Right, + AgentPosition::Top => plugin::PanelSide::Top, + AgentPosition::Bottom => plugin::PanelSide::Bottom, + }; + let side = if self.config.agent.responsive + && matches!( + requested_side, + plugin::PanelSide::Left | plugin::PanelSide::Right + ) + && terminal_width + < MIN_AGENT_EDITOR_WIDTH + .saturating_add(MIN_AGENT_PANEL_WIDTH) + .saturating_add(1) + { + plugin::PanelSide::Bottom + } else { + requested_side + }; + let (available, percentage, minimum, editor_minimum) = match side { + plugin::PanelSide::Left | plugin::PanelSide::Right => ( + terminal_width, + self.config.agent.width_percent, + MIN_AGENT_PANEL_WIDTH, + MIN_AGENT_EDITOR_WIDTH, + ), + plugin::PanelSide::Top | plugin::PanelSide::Bottom => ( + terminal_height, + self.config.agent.height_percent, + MIN_AGENT_PANEL_HEIGHT, + MIN_AGENT_EDITOR_HEIGHT, + ), + }; + let max_thickness = available.saturating_sub(editor_minimum).saturating_sub(1); + let thickness = if max_thickness == 0 { + 0 + } else { + available + .saturating_mul(usize::from(percentage.min(100))) + .saturating_div(100) + .clamp(minimum.min(max_thickness), max_thickness) + }; + (side, thickness) + } + + fn refresh_agent_panel_layout(&mut self) -> bool { + let (side, width) = self.configured_agent_panel_layout(); + self.panel_manager + .update_panel_layout("agent-conversation", side, width) + } + fn indentation(&self) -> Indentation { self.indentation_for_buffer_index(self.buffer_manager.active_index()) } @@ -3420,6 +3554,11 @@ impl Editor { .unwrap_or_else(|| (self.size.1 as usize).saturating_sub(2)) } + /// Whether popup and dock borders should use terminal-safe ASCII glyphs. + pub(crate) fn window_borders_ascii(&self) -> bool { + self.config.window_borders_ascii + } + pub(crate) fn picker_input_position(&self) -> crate::config::PickerInputPosition { self.config.picker.input_position } @@ -4949,6 +5088,138 @@ impl Editor { Ok(()) } + /// Reconciles completed native Codex edits without overwriting unsaved buffers. + async fn reconcile_native_agent_file_changes( + &mut self, + session_id: &str, + update: &Value, + runtime: &mut Runtime, + ) -> anyhow::Result { + if matches!(self.config.agent.mode, AgentMode::Review) + || update.get("session_update").and_then(Value::as_str) != Some("tool_call_update") + || !matches!( + update.get("kind").and_then(Value::as_str), + Some("fileChange" | "file_change") + ) + || update.get("status").and_then(Value::as_str) != Some("completed") + { + return Ok(false); + } + + let Some(workspace) = self.agent_manager.workspace_cloned() else { + return Ok(false); + }; + let root = workspace + .lock() + .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))? + .root() + .to_path_buf(); + + let Some(changes) = update.pointer("/item/changes").and_then(Value::as_array) else { + return Ok(false); + }; + + let mut seen = HashSet::new(); + let mut reloaded = Vec::new(); + let mut conflicts = Vec::new(); + for change in changes.iter().take(crate::agent_tools::MAX_EDITOR_EDITS) { + let Some(path) = change.get("path").and_then(Value::as_str) else { + continue; + }; + let reported = Path::new(path); + let path = if reported.is_absolute() { + reported.to_path_buf() + } else { + root.join(reported) + }; + if crate::agent_tools::ensure_agent_path_disclosable(&root, &path).is_err() + || !seen.insert(path.clone()) + { + continue; + } + + let indices = self + .buffer_manager + .iter() + .enumerate() + .filter_map(|(index, buffer)| { + let open_path = Path::new(buffer.file.as_deref()?).absolutize().ok()?; + (open_path.as_ref() == path.as_path()).then_some(index) + }) + .collect::>(); + + for index in indices { + let buffer = &mut self.buffer_manager[index]; + if buffer.is_dirty() { + conflicts.push((path.clone(), "the open buffer has unsaved changes")); + continue; + } + if buffer.reload_from_file().is_err() { + conflicts.push((path.clone(), "the changed file could not be reloaded")); + continue; + } + reloaded.push(index); + } + } + + let changed = !reloaded.is_empty(); + for index in reloaded { + if index == self.buffer_manager.active_index() { + self.check_bounds(); + self.sync_to_window(); + self.notify_change(runtime).await?; + continue; + } + + let Some(buffer) = self.buffer_manager.get(index) else { + continue; + }; + let file = buffer.file.clone(); + let contents = buffer.contents(); + let payload = json!({ + "buffer_id": index, + "buffer_name": buffer.name(), + "file_path": file, + "revision": buffer.revision(), + "line_count": buffer.len(), + "source": "codex", + }); + if self.config.lsp.enabled { + if let Some(file) = file.as_deref() { + self.ensure_buffer_lsp_opened(index).await?; + self.lsp.did_change(file, contents).await?; + } + } + self.plugin_registry + .notify(runtime, "buffer:changed", payload) + .await?; + } + + if changed { + self.sync_agent_visible_buffers(&workspace)?; + } + for (path, reason) in conflicts { + let message = format!( + "Codex changed {} but {reason}; unsaved editor contents were preserved", + path.display() + ); + self.last_error = Some(message.clone()); + self.plugin_registry + .notify( + runtime, + "agent:file_conflict", + json!({ + "session_id": session_id, + "path": path, + "message": message, + }), + ) + .await?; + } + + Ok(changed) + } + fn agent_context_payload(&self) -> Value { const CONTEXT_LINES: usize = 40; const MAX_CONTEXT_CHARS: usize = 40_000; @@ -5918,6 +6189,7 @@ impl Editor { self.plugin_registry.poll_hot_reload(runtime).await; let mut proposal_sessions = Vec::new(); + let mut native_files_changed = false; for _ in 0..AGENT_EVENTS_PER_TICK { let Some(event) = self .agent_manager @@ -5926,6 +6198,18 @@ impl Editor { else { break; }; + if let CodexEvent::SessionCreated { session_id } = &event { + if self.config.agent.persistent_threads { + let key = scoped_plugin_storage_key("agent", "thread_id"); + if let Err(error) = self.preferences.set_plugin_storage( + "agent", + &key, + Value::String(session_id.clone()), + ) { + log!("Unable to persist Codex thread for recovery: {error}"); + } + } + } if let CodexEvent::Completed { session_id, .. } | CodexEvent::Failed { session_id: Some(session_id), @@ -5935,11 +6219,20 @@ impl Editor { self.agent_manager.mark_session_inactive(session_id); } match &event { - CodexEvent::Update { session_id, .. } | CodexEvent::Activity { session_id, .. } + CodexEvent::Update { session_id, .. } if !self.agent_manager.is_session_active(session_id) => { continue; } + CodexEvent::Activity { session_id, update } + if !self.agent_manager.is_session_active(session_id) + && !matches!( + update.get("session_update").and_then(Value::as_str), + Some("models" | "sessions" | "model_selected" | "token_usage") + ) => + { + continue; + } CodexEvent::PermissionRequested { request_id, session_id, @@ -5955,6 +6248,11 @@ impl Editor { } _ => {} } + if let CodexEvent::Activity { session_id, update } = &event { + native_files_changed |= self + .reconcile_native_agent_file_changes(session_id, update, runtime) + .await?; + } if let CodexEvent::Update { session_id, .. } | CodexEvent::Activity { session_id, .. } | CodexEvent::Completed { session_id, .. } @@ -6052,7 +6350,7 @@ impl Editor { // Coalesce background work (LSP messages, plugin requests) into a // single render at the end of the tick instead of one per item. - let mut needs_render = false; + let mut needs_render = native_files_changed; let mut needs_motion_render = false; let mut agent_proposal_applied = false; @@ -6170,8 +6468,28 @@ impl Editor { } }; self.sync_agent_visible_buffers(&workspace)?; + let execution_mode = match self.config.agent.mode { + AgentMode::Agent | AgentMode::Plan => { + CodexExecutionMode::Native + } + AgentMode::Review => CodexExecutionMode::ReviewSafe, + }; let mut spec = CodexProcessSpec::new(command, cwd.clone()) - .args(self.config.agent.args.clone()); + .args(self.config.agent.args.clone()) + .execution_mode(execution_mode) + .persistent_threads(self.config.agent.persistent_threads) + .plan_mode(matches!( + self.config.agent.mode, + AgentMode::Plan + )); + if let Some(model) = self.config.agent.model.as_deref() { + spec = spec.model(model); + } + if let Some(effort) = + self.config.agent.reasoning_effort.as_deref() + { + spec = spec.reasoning_effort(effort); + } spec.environment.extend( self.config .agent @@ -6218,7 +6536,79 @@ impl Editor { let Some(bridge) = self.agent_manager.bridge() else { continue; }; - if bridge.send(CodexCommand::NewSession { cwd }).await.is_err() { + let saved_thread = self + .config + .agent + .persistent_threads + .then(|| { + self.preferences + .plugin_storage( + "agent", + &scoped_plugin_storage_key("agent", "thread_id"), + ) + .and_then(Value::as_str) + .filter(|thread_id| !thread_id.is_empty()) + .map(str::to_string) + }) + .flatten(); + let command = match saved_thread { + Some(session_id) => CodexCommand::RecoverSession { session_id, cwd }, + None => CodexCommand::NewSession { cwd }, + }; + if bridge.send(command).await.is_err() { + self.abort_agent_bridge(); + self.plugin_registry + .notify( + runtime, + "agent:session_lost", + json!({ "message": "Codex app-server stopped" }), + ) + .await?; + } + } + PluginRequest::AgentResumeSession { session_id, cwd } => { + if self.config.disable_ai { + self.plugin_registry + .notify( + runtime, + "agent:error", + json!({ + "session_id": session_id, + "message": "agent support is disabled by `disable_ai = true`", + }), + ) + .await?; + continue; + } + if !self.agent_manager.has_bridge() { + let key = scoped_plugin_storage_key("agent", "thread_id"); + if let Err(error) = self.preferences.set_plugin_storage( + "agent", + &key, + Value::String(session_id), + ) { + self.plugin_registry + .notify( + runtime, + "agent:error", + json!({ "message": format!( + "unable to prepare Codex conversation recovery: {error}" + ) }), + ) + .await?; + continue; + } + ACTION_DISPATCHER.send_request(PluginRequest::AgentNewSession { cwd }); + continue; + } + let Some(bridge) = self.agent_manager.bridge() else { + continue; + }; + if bridge + .send(CodexCommand::ResumeSession { session_id, cwd }) + .await + .is_err() + { self.abort_agent_bridge(); self.plugin_registry .notify( @@ -6250,66 +6640,191 @@ impl Editor { .dispatch_agent_prompt(runtime, session_id, text, Some((uri, context))) .await?; } - PluginRequest::AgentCancel { session_id } => { + PluginRequest::AgentSteer { session_id, text } => { let Some(bridge) = self.agent_manager.bridge() else { - continue; - }; - if bridge - .send(CodexCommand::Cancel { session_id }) - .await - .is_err() - { self.plugin_registry .notify( runtime, "agent:error", - json!({ "message": "Codex app-server stopped" }), + json!({ + "session_id": session_id, + "message": "Codex app-server is not running", + }), ) .await?; - } - } - PluginRequest::AgentCloseSession { session_id } => { - self.agent_manager.mark_session_inactive(&session_id); - if let Some(workspace) = self.agent_manager.workspace() { - workspace - .lock() - .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))? - .archive_session(&session_id); - } - let Some(bridge) = self.agent_manager.bridge() else { continue; }; if bridge - .send(CodexCommand::CloseSession { session_id }) + .send(CodexCommand::Steer { session_id, text }) .await .is_err() { + self.abort_agent_bridge(); self.plugin_registry .notify( runtime, - "agent:error", + "agent:session_lost", json!({ "message": "Codex app-server stopped" }), ) .await?; } } - PluginRequest::AgentArchiveSession { session_id } => { - self.agent_manager.mark_session_inactive(&session_id); - if let Some(workspace) = self.agent_manager.workspace() { - workspace - .lock() - .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))? - .archive_session(&session_id); + PluginRequest::AgentListModels { session_id } => { + if let Some(bridge) = self.agent_manager.bridge() { + if bridge + .send(CodexCommand::ListModels { session_id }) + .await + .is_err() + { + self.plugin_registry + .notify( + runtime, + "agent:error", + json!({ "message": "Codex app-server stopped" }), + ) + .await?; + } } } - PluginRequest::AgentProposals { - session_id, - request_id, - } => { - let mut payload = match self.agent_proposals_payload(&session_id) { - Ok(payload) => payload, - Err(_) => { - let message = + PluginRequest::AgentListSessions { session_id, cwd } => { + if let Some(bridge) = self.agent_manager.bridge() { + if bridge + .send(CodexCommand::ListSessions { session_id, cwd }) + .await + .is_err() + { + self.plugin_registry + .notify( + runtime, + "agent:error", + json!({ "message": "Codex app-server stopped" }), + ) + .await?; + } + } + } + PluginRequest::AgentSetModel { + session_id, + model, + reasoning_effort, + } => { + let Some(bridge) = self.agent_manager.bridge() else { + self.plugin_registry + .notify( + runtime, + "agent:error", + json!({ + "session_id": session_id, + "message": "Codex app-server is not running", + }), + ) + .await?; + continue; + }; + if bridge + .send(CodexCommand::SetModel { + session_id, + model: model.clone(), + reasoning_effort: reasoning_effort.clone(), + }) + .await + .is_err() + { + self.abort_agent_bridge(); + self.plugin_registry + .notify( + runtime, + "agent:session_lost", + json!({ "message": "Codex app-server stopped" }), + ) + .await?; + continue; + } + self.config.agent.model = Some(model); + self.config.agent.reasoning_effort = reasoning_effort; + needs_render = true; + } + PluginRequest::AgentSetReasoningEffort { effort } => { + if self.config.agent.reasoning_effort.as_deref() != Some(effort.as_str()) { + self.config.agent.reasoning_effort = Some(effort); + self.abort_agent_bridge(); + needs_render = true; + } + } + PluginRequest::AgentCancel { session_id } => { + let Some(bridge) = self.agent_manager.bridge() else { + continue; + }; + if bridge + .send(CodexCommand::Cancel { session_id }) + .await + .is_err() + { + self.plugin_registry + .notify( + runtime, + "agent:error", + json!({ "message": "Codex app-server stopped" }), + ) + .await?; + } + } + PluginRequest::AgentCloseSession { session_id } => { + self.agent_manager.mark_session_inactive(&session_id); + let thread_key = scoped_plugin_storage_key("agent", "thread_id"); + if self + .preferences + .plugin_storage("agent", &thread_key) + .and_then(Value::as_str) + .is_some_and(|thread_id| thread_id == session_id) + { + if let Err(error) = + self.preferences + .set_plugin_storage("agent", &thread_key, Value::Null) + { + log!("Unable to clear closed Codex recovery thread: {error}"); + } + } + if let Some(workspace) = self.agent_manager.workspace() { + workspace + .lock() + .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))? + .archive_session(&session_id); + } + let Some(bridge) = self.agent_manager.bridge() else { + continue; + }; + if bridge + .send(CodexCommand::CloseSession { session_id }) + .await + .is_err() + { + self.plugin_registry + .notify( + runtime, + "agent:error", + json!({ "message": "Codex app-server stopped" }), + ) + .await?; + } + } + PluginRequest::AgentArchiveSession { session_id } => { + self.agent_manager.mark_session_inactive(&session_id); + if let Some(workspace) = self.agent_manager.workspace() { + workspace + .lock() + .map_err(|_| anyhow::anyhow!("proposal workspace lock is poisoned"))? + .archive_session(&session_id); + } + } + PluginRequest::AgentProposals { + session_id, + request_id, + } => { + let mut payload = match self.agent_proposals_payload(&session_id) { + Ok(payload) => payload, + Err(_) => { + let message = "Unable to review agent proposals safely; pending changes were left intact"; self.last_error = Some(message.to_string()); needs_render = true; @@ -6526,6 +7041,16 @@ impl Editor { } if owner == "agent" { self.remove_legacy_agent_picker_history(); + if matches!(self.config.agent.entry, AgentEntry::Dock) + && query.trim().is_empty() + { + runtime.release_composer(handle); + self.plugin_registry + .notify_plugin(runtime, "agent", "agent:open_dock", json!({})) + .await?; + needs_render = true; + continue; + } } self.release_current_dialog_callbacks(runtime); self.current_dialog = Some(Box::new(AgentComposer::new_callback( @@ -6864,6 +7389,7 @@ impl Editor { let config_value = if let Some(key) = key { // Return specific config value match key.as_str() { + "agent" => json!(self.config.agent), "theme" => json!(self.config.theme), "plugins" => json!(self.config.plugins), "plugin_config" => json!(self.config.plugin_config), @@ -6884,6 +7410,7 @@ impl Editor { } else { // Return entire config json!({ + "agent": self.config.agent, "theme": self.config.theme, "plugins": self.config.plugins, "plugin_config": self.config.plugin_config, @@ -7304,6 +7831,16 @@ impl Editor { needs_render = true; } PluginRequest::CreateTextPanel { id, config } => { + let config = if id == "agent-conversation" { + let (side, width) = self.configured_agent_panel_layout(); + plugin::PanelConfig { + side, + width, + ..config + } + } else { + config + }; self.panel_manager.create_text_panel(id, config); self.apply_panel_layout(); needs_render = true; @@ -7381,6 +7918,11 @@ impl Editor { needs_render = true; } } + PluginRequest::SetAgentPosition { position } => { + self.config.agent.position = position; + self.apply_panel_layout(); + needs_render = true; + } PluginRequest::ClosePanel { id } => { self.panel_manager.close_panel(&id); self.apply_panel_layout(); @@ -9525,7 +10067,7 @@ impl Editor { } KeyCode::Char('H') => "history", KeyCode::Char('N') => "new", - KeyCode::Char('a') if !self.panel_manager.focused_row_panel() => { + KeyCode::Char('i' | 'a') if !self.panel_manager.focused_row_panel() => { "composer_focus" } KeyCode::Char('x') if !self.panel_manager.focused_row_panel() => "clear", @@ -15215,32 +15757,16 @@ impl Editor { .await?; } Action::MoveWindowToLeft => { - if self.update_window_layout(|windows| { - windows.move_window_to_edge(crate::window::Direction::Left) - }) { - self.render(buffer)?; - } + self.move_focused_window_to_edge(crate::window::Direction::Left, buffer)?; } Action::MoveWindowToBottom => { - if self.update_window_layout(|windows| { - windows.move_window_to_edge(crate::window::Direction::Down) - }) { - self.render(buffer)?; - } + self.move_focused_window_to_edge(crate::window::Direction::Down, buffer)?; } Action::MoveWindowToTop => { - if self.update_window_layout(|windows| { - windows.move_window_to_edge(crate::window::Direction::Up) - }) { - self.render(buffer)?; - } + self.move_focused_window_to_edge(crate::window::Direction::Up, buffer)?; } Action::MoveWindowToRight => { - if self.update_window_layout(|windows| { - windows.move_window_to_edge(crate::window::Direction::Right) - }) { - self.render(buffer)?; - } + self.move_focused_window_to_edge(crate::window::Direction::Right, buffer)?; } Action::ResizeWindowUp(amount) => { if self.update_window_layout(|windows| { @@ -17962,6 +18488,17 @@ impl Editor { .clone() .map(|workspace| Arc::new(Mutex::new(ProposalWorkspace::from_snapshot(workspace)))), ); + if self.config.agent.persistent_threads && snapshot.agent_session_resumable { + if let Some(thread_id) = snapshot.agent_thread_id.as_deref() { + if let Err(error) = self.preferences.set_plugin_storage( + "agent", + &scoped_plugin_storage_key("agent", "thread_id"), + Value::String(thread_id.to_string()), + ) { + log!("Unable to recover persisted Codex thread: {error}"); + } + } + } if let Some(transcript) = &snapshot.agent_transcript { let transcript_persisted = if let Err(error) = self.preferences.set_plugin_storage( "agent", @@ -18137,6 +18674,19 @@ impl Editor { .plugin_storage("agent", &scoped_plugin_storage_key("agent", "transcript")) .and_then(Value::as_str) .map(str::to_string); + let agent_thread_id = self + .config + .agent + .persistent_threads + .then(|| { + self.preferences + .plugin_storage("agent", &scoped_plugin_storage_key("agent", "thread_id")) + .and_then(Value::as_str) + .filter(|thread_id| !thread_id.is_empty()) + .map(str::to_string) + }) + .flatten(); + let agent_session_resumable = agent_thread_id.is_some(); ( SessionSnapshot { @@ -18162,8 +18712,9 @@ impl Editor { global_marks, special_marks, agent_transcript, + agent_thread_id, agent_workspace, - agent_session_resumable: false, + agent_session_resumable, }, disk_fingerprints, ) @@ -20549,6 +21100,11 @@ impl Editor { .map(|window| (window.position, window.size)) } + #[doc(hidden)] + pub fn test_agent_position(&self) -> crate::config::AgentPosition { + self.config.agent.position + } + #[doc(hidden)] pub fn test_create_panel(&mut self, id: &str, config: plugin::PanelConfig) { self.panel_manager.create_panel(id.to_string(), config); @@ -20615,6 +21171,11 @@ impl Editor { self.render_cursor_position() } + #[doc(hidden)] + pub fn test_active_cursor_shape(&self) -> crate::config::CursorShape { + self.active_cursor_shape() + } + #[doc(hidden)] pub fn test_is_waiting_for_key_sequence(&self) -> bool { self.is_waiting_for_key_sequence() @@ -20943,6 +21504,319 @@ mod test { assert_eq!(payload["update"]["tool_call_id"], "tool-1"); } + #[tokio::test] + async fn native_agent_file_changes_reload_clean_buffers_and_sync_visible_contents() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("main.rs"); + std::fs::write(&path, "before\n").unwrap(); + let workspace = Arc::new(Mutex::new(ProposalWorkspace::new(root.path()).unwrap())); + let mut editor = test_editor(/*width*/ 80, /*height*/ 24); + editor.config.lsp.enabled = false; + editor.buffer_manager.replace_buffers(vec![Buffer::new( + Some(path.to_string_lossy().into_owned()), + "before\n".to_string(), + )]); + editor + .agent_manager + .set_workspace(Some(Arc::clone(&workspace))); + editor.sync_agent_visible_buffers(&workspace).unwrap(); + std::fs::write(&path, "updated by Codex\n").unwrap(); + let update = json!({ + "session_update": "tool_call_update", + "kind": "fileChange", + "status": "completed", + "item": { "changes": [{ "path": "main.rs" }] }, + }); + + let changed = editor + .reconcile_native_agent_file_changes("session-1", &update, &mut Runtime::new()) + .await + .unwrap(); + + assert!(changed); + assert_eq!(editor.current_buffer().contents(), "updated by Codex\n"); + assert!(!editor.current_buffer().is_dirty()); + assert_eq!( + workspace + .lock() + .unwrap() + .read("session-1", &path, None, None) + .unwrap(), + "updated by Codex\n" + ); + } + + #[tokio::test] + async fn native_agent_file_changes_preserve_dirty_buffers_and_report_conflicts() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("main.rs"); + std::fs::write(&path, "before\n").unwrap(); + let workspace = Arc::new(Mutex::new(ProposalWorkspace::new(root.path()).unwrap())); + let mut editor = test_editor(/*width*/ 80, /*height*/ 24); + editor.config.lsp.enabled = false; + let mut unsaved = Buffer::new( + Some(path.to_string_lossy().into_owned()), + "my unsaved changes\n".to_string(), + ); + unsaved.dirty = true; + editor.buffer_manager.replace_buffers(vec![unsaved]); + editor + .agent_manager + .set_workspace(Some(Arc::clone(&workspace))); + editor.sync_agent_visible_buffers(&workspace).unwrap(); + std::fs::write(&path, "updated by Codex\n").unwrap(); + let update = json!({ + "session_update": "tool_call_update", + "kind": "fileChange", + "status": "completed", + "item": { "changes": [{ "path": "main.rs" }] }, + }); + + let changed = editor + .reconcile_native_agent_file_changes("session-1", &update, &mut Runtime::new()) + .await + .unwrap(); + + assert!(!changed); + assert!(editor.current_buffer().is_dirty()); + assert_eq!(editor.current_buffer().contents(), "my unsaved changes\n"); + assert!(editor + .last_error + .as_deref() + .is_some_and(|message| message.contains("unsaved editor contents were preserved"))); + assert_eq!( + workspace + .lock() + .unwrap() + .read("session-1", &path, None, None) + .unwrap(), + "my unsaved changes\n" + ); + } + + #[tokio::test] + async fn review_safe_agent_file_activity_never_reloads_workspace_buffers() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("main.rs"); + std::fs::write(&path, "before\n").unwrap(); + let workspace = Arc::new(Mutex::new(ProposalWorkspace::new(root.path()).unwrap())); + let mut editor = test_editor(/*width*/ 80, /*height*/ 24); + editor.config.agent.mode = AgentMode::Review; + editor.config.lsp.enabled = false; + editor.buffer_manager.replace_buffers(vec![Buffer::new( + Some(path.to_string_lossy().into_owned()), + "before\n".to_string(), + )]); + editor.agent_manager.set_workspace(Some(workspace)); + std::fs::write(&path, "external change\n").unwrap(); + let update = json!({ + "session_update": "tool_call_update", + "kind": "fileChange", + "status": "completed", + "item": { "changes": [{ "path": "main.rs" }] }, + }); + + assert!(!editor + .reconcile_native_agent_file_changes("session-1", &update, &mut Runtime::new()) + .await + .unwrap()); + assert_eq!(editor.current_buffer().contents(), "before\n"); + } + + #[test] + fn configured_agent_layout_respects_all_four_positions_and_percentages() { + let mut editor = test_editor(/*width*/ 100, /*height*/ 30); + editor.config.agent.width_percent = 40; + editor.config.agent.height_percent = 25; + + for (position, expected_side, expected_thickness) in [ + (AgentPosition::Left, plugin::PanelSide::Left, 40), + (AgentPosition::Right, plugin::PanelSide::Right, 40), + (AgentPosition::Top, plugin::PanelSide::Top, 7), + (AgentPosition::Bottom, plugin::PanelSide::Bottom, 7), + ] { + editor.config.agent.position = position; + + assert_eq!( + editor.configured_agent_panel_layout(), + (expected_side, expected_thickness), + ); + } + } + + #[tokio::test] + async fn edge_moves_preserve_focused_agent_transcript_composer_and_draft() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + let mut editor = test_editor(/*width*/ 100, /*height*/ 30); + editor.test_create_text_panel( + "agent-conversation", + plugin::PanelConfig { + side: plugin::PanelSide::Right, + composer: Some(plugin::TextPanelComposerConfig { + placeholder: "Ask the agent".to_string(), + rows: 3, + }), + ..plugin::PanelConfig::default() + }, + ); + editor.panel_manager.update_text_panel( + "agent-conversation", + vec![plugin::TextPanelBlock { + id: "agent-turn".to_string(), + kind: plugin::TextPanelBlockKind::Agent, + format: plugin::TextPanelBlockFormat::Plain, + text: "Keep the streamed conversation 👋".to_string(), + }], + /*panel_height*/ 28, + /*terminal_width*/ 100, + ); + assert!(editor + .panel_manager + .focus_text_panel_composer("agent-conversation")); + let drafted = editor + .panel_manager + .handle_focused_text_input( + &Event::Paste("Preserve this draft 👨‍👩‍👧".to_string()), + /*terminal_width*/ 100, + ) + .expect("focused agent composer must receive the draft"); + assert_eq!(drafted.action, "composer_input"); + + let mut render_buffer = + RenderBuffer::new(/*width*/ 100, /*height*/ 30, &Style::default()); + let mut runtime = Runtime::new(); + for (action, position) in [ + (Action::MoveWindowToLeft, AgentPosition::Left), + (Action::MoveWindowToBottom, AgentPosition::Bottom), + (Action::MoveWindowToTop, AgentPosition::Top), + (Action::MoveWindowToRight, AgentPosition::Right), + ] { + editor + .execute(&action, &mut render_buffer, &mut runtime) + .await + .unwrap(); + + assert_eq!(editor.test_agent_position(), position); + assert_eq!( + editor.panel_manager.focused_panel_id(), + Some("agent-conversation") + ); + assert!(editor.panel_manager.focused_text_input_active()); + assert_eq!( + editor.panel_manager.focused_text_for_copy(/*all*/ false), + Some("Keep the streamed conversation 👋".to_string()) + ); + let (cursor_x, cursor_y) = editor + .render_cursor_position() + .expect("the relocated agent composer must retain its real cursor"); + assert_eq!( + editor + .panel_manager + .panel_at_position(cursor_x, cursor_y, /*width*/ 100, /*height*/ 30) + .map(|placement| placement.id), + Some("agent-conversation".to_string()) + ); + } + + let submitted = editor + .panel_manager + .handle_focused_text_input( + &Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)), + /*terminal_width*/ 100, + ) + .expect("the preserved draft must remain submittable"); + assert_eq!(submitted.action, "submit"); + assert_eq!(submitted.text.as_deref(), Some("Preserve this draft 👨‍👩‍👧")); + } + + #[test] + fn narrow_agent_side_dock_falls_back_to_bottom_and_restores_on_resize() { + let mut editor = test_editor(/*width*/ 50, /*height*/ 24); + editor.config.agent.position = AgentPosition::Right; + editor.config.agent.responsive = true; + + assert_eq!( + editor.configured_agent_panel_layout().0, + plugin::PanelSide::Bottom + ); + + editor.size = (100, 24); + assert_eq!( + editor.configured_agent_panel_layout().0, + plugin::PanelSide::Right + ); + + editor.size = (50, 24); + editor.config.agent.responsive = false; + assert_eq!( + editor.configured_agent_panel_layout().0, + plugin::PanelSide::Right + ); + } + + #[tokio::test] + async fn docked_agent_entry_releases_the_float_and_focuses_the_real_modal_dock() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_plugin_requests(); + let mut editor = test_editor(/*width*/ 100, /*height*/ 30); + editor.config.agent.entry = AgentEntry::Dock; + editor.config.lsp.enabled = false; + let mut render_buffer = RenderBuffer::new(100, 30, &Style::default()); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../plugins/agent.hk")) + .await + .unwrap(); + + runtime.execute_command("Agent").await.unwrap(); + let request_id = match ACTION_DISPATCHER.recv_request() { + PluginRequest::GetPluginStorage { + plugin, + key, + request_id, + } => { + assert_eq!(plugin, "agent"); + assert_eq!(key, "prompt_history"); + request_id + } + _ => panic!("docked entry must first restore the agent prompt history"), + }; + runtime + .resolve_request(request_id, json!({ "value": [] })) + .await + .unwrap(); + let callback = ACTION_DISPATCHER.recv_request(); + let handle = match &callback { + PluginRequest::OpenCallbackComposer { owner, handle, .. } => { + assert_eq!(owner, "agent"); + *handle + } + _ => panic!("agent prompt must retain its scoped composer callback"), + }; + ACTION_DISPATCHER.send_request(callback); + + editor + .service_background(&mut render_buffer, &mut runtime) + .await + .unwrap(); + + assert!(editor.current_dialog.is_none()); + assert_eq!( + editor.panel_manager.focused_panel_id(), + Some("agent-conversation") + ); + assert!(editor.panel_manager.focused_text_input_active()); + assert_eq!(runtime.composer_plugin(handle), None); + assert!(!editor.agent_manager.has_bridge()); + } + #[tokio::test] async fn agent_composer_never_uses_picker_history_or_records_prompt_input() { let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; @@ -20988,7 +21862,7 @@ mod test { .unwrap(); editor .process_editor_event( - Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)), &mut buffer, &mut runtime, EventRenderMode::Immediate, @@ -21014,6 +21888,89 @@ mod test { ); } + #[tokio::test] + async fn floating_agent_composer_submits_modified_csi_u_line_endings_in_insert_mode() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + + for (code, modifiers, kind) in [ + ( + KeyCode::Enter, + KeyModifiers::CONTROL, + KeyEventKind::Press, + ), + ( + KeyCode::Char('\n'), + KeyModifiers::CONTROL, + KeyEventKind::Press, + ), + ( + KeyCode::Char('\r'), + KeyModifiers::CONTROL, + KeyEventKind::Press, + ), + ( + KeyCode::Char('\n'), + KeyModifiers::CONTROL, + KeyEventKind::Repeat, + ), + ( + KeyCode::Char('\r'), + KeyModifiers::CONTROL, + KeyEventKind::Repeat, + ), + (KeyCode::Char('\n'), KeyModifiers::ALT, KeyEventKind::Press), + (KeyCode::Char('\r'), KeyModifiers::ALT, KeyEventKind::Press), + ] { + drain_plugin_requests(); + let mut editor = test_editor(/*width*/ 80, /*height*/ 14); + let mut buffer = RenderBuffer::new(/*width*/ 80, /*height*/ 14, &Style::default()); + let mut runtime = Runtime::new(); + ACTION_DISPATCHER.send_request(PluginRequest::OpenAgentComposer { + owner: "agent".to_string(), + title: Some("Agent prompt".to_string()), + id: 802, + query: String::new(), + history: Vec::new(), + }); + + editor + .service_background(&mut buffer, &mut runtime) + .await + .unwrap(); + assert_eq!( + editor + .current_dialog + .as_ref() + .and_then(|dialog| dialog.cursor_mode()), + Some(Mode::Insert), + ); + editor + .process_editor_event( + Event::Paste("send directly from Insert mode".to_string()), + &mut buffer, + &mut runtime, + EventRenderMode::Immediate, + ) + .await + .unwrap(); + + editor + .process_editor_event( + Event::Key(KeyEvent::new_with_kind(code, modifiers, kind)), + &mut buffer, + &mut runtime, + EventRenderMode::Immediate, + ) + .await + .unwrap(); + + assert!( + editor.current_dialog.is_none(), + "{code:?} with {modifiers:?} on {kind:?} must submit from Insert mode", + ); + } + } + #[tokio::test] async fn opening_a_custom_agent_composer_preserves_unrelated_picker_history() { let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; @@ -21687,6 +22644,132 @@ mod test { ); } + #[test] + fn detached_agent_input_preserves_modified_csi_u_line_endings() { + for character in ['\n', '\r'] { + for (modifiers, expected_modifiers) in [ + ( + vec![crate::headless::KeyModifier::Control], + KeyModifiers::CONTROL, + ), + ( + vec![crate::headless::KeyModifier::Alt], + KeyModifiers::ALT, + ), + ( + vec![ + crate::headless::KeyModifier::Control, + crate::headless::KeyModifier::Shift, + ], + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + ( + vec![ + crate::headless::KeyModifier::Alt, + crate::headless::KeyModifier::Shift, + ], + KeyModifiers::ALT | KeyModifiers::SHIFT, + ), + ] { + assert_eq!( + detached_input_to_crossterm(crate::headless::InputEvent::Key { + code: crate::headless::KeyCode::Character(character), + modifiers, + }), + Event::Key(KeyEvent::new( + KeyCode::Char(character), + expected_modifiers, + )), + "detached input must preserve modified CSI-u character {character:?}", + ); + } + } + + assert_eq!( + detached_input_to_crossterm(crate::headless::InputEvent::Key { + code: crate::headless::KeyCode::Enter, + modifiers: Vec::new(), + }), + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + "ordinary Enter must remain an unmodified newline in Insert mode", + ); + assert_eq!( + detached_input_to_crossterm(crate::headless::InputEvent::Key { + code: crate::headless::KeyCode::Character('j'), + modifiers: vec![crate::headless::KeyModifier::Control], + }), + Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)), + "Ctrl+J must remain distinct from modified Enter", + ); + } + + #[tokio::test] + async fn detached_agent_composer_submits_modified_csi_u_line_endings_in_insert_mode() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + let cases: &[(crate::headless::KeyCode, &[crate::headless::KeyModifier])] = &[ + ( + crate::headless::KeyCode::Enter, + &[crate::headless::KeyModifier::Control], + ), + ( + crate::headless::KeyCode::Character('\n'), + &[crate::headless::KeyModifier::Control], + ), + ( + crate::headless::KeyCode::Character('\r'), + &[crate::headless::KeyModifier::Control], + ), + ( + crate::headless::KeyCode::Character('\n'), + &[crate::headless::KeyModifier::Alt], + ), + ( + crate::headless::KeyCode::Character('\r'), + &[crate::headless::KeyModifier::Alt], + ), + ]; + + for &(code, modifiers) in cases { + drain_plugin_requests(); + let mut core = DetachedEditorCore::new(test_editor(/*width*/ 80, /*height*/ 24)) + .await + .unwrap(); + ACTION_DISPATCHER.send_request(PluginRequest::OpenAgentComposer { + owner: "agent".to_string(), + title: Some("Agent prompt".to_string()), + id: 802, + query: String::new(), + history: Vec::new(), + }); + + core.tick().await.unwrap().expect("composer render"); + assert_eq!( + core.editor + .current_dialog + .as_ref() + .and_then(|dialog| dialog.cursor_mode()), + Some(Mode::Insert), + ); + core.input(crate::headless::InputEvent::Paste { + text: "send directly from detached Insert mode".to_string(), + }) + .await + .unwrap(); + + core.input(crate::headless::InputEvent::Key { + code, + modifiers: modifiers.to_vec(), + }) + .await + .unwrap(); + + assert!( + core.editor.current_dialog.is_none(), + "detached {code:?} with {modifiers:?} must submit from Insert mode", + ); + } + } + #[tokio::test] async fn detached_agent_composer_wraps_paste_and_keeps_the_cursor_in_bounds_after_resize() { let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; diff --git a/src/editor/rendering.rs b/src/editor/rendering.rs index 36a32aab..1fca9a77 100644 --- a/src/editor/rendering.rs +++ b/src/editor/rendering.rs @@ -260,7 +260,12 @@ impl Editor { // Startup splash over the pristine scratch window (docs/SPLASH.md) self.render_splash(buffer); - self.panel_manager.render(buffer, &self.theme); + self.panel_manager.render_with_highlighter( + buffer, + &self.theme, + &mut self.highlighter, + self.config.window_borders_ascii, + ); // Render global UI elements let chrome_span = super::perf::PerfSpan::start("render:chrome"); @@ -2023,26 +2028,38 @@ impl Editor { } } + pub(crate) fn active_cursor_shape(&self) -> CursorShape { + if self.is_waiting_for_key_sequence() { + return self.config.cursor.waiting; + } + + let mode = if let Some(dialog) = self.current_dialog.as_ref() { + dialog.cursor_mode().unwrap_or(self.mode) + } else { + self.panel_manager + .focused_text_panel_cursor_mode() + .unwrap_or(self.mode) + }; + + match mode { + Mode::Normal => self.config.cursor.normal, + Mode::Command => self.config.cursor.command, + Mode::Insert => self.config.cursor.insert, + Mode::Search => self.config.cursor.search, + Mode::Visual => self.config.cursor.visual, + Mode::VisualLine => self.config.cursor.visual_line, + Mode::VisualBlock => self.config.cursor.visual_block, + } + } + fn set_cursor_style(&mut self) -> anyhow::Result<()> { if !self.terminal_output_enabled { return Ok(()); } self.queue_theme_cursor_color()?; - let shape = if self.is_waiting_for_key_sequence() { - self.config.cursor.waiting - } else { - match self.mode { - Mode::Normal => self.config.cursor.normal, - Mode::Command => self.config.cursor.command, - Mode::Insert => self.config.cursor.insert, - Mode::Search => self.config.cursor.search, - Mode::Visual => self.config.cursor.visual, - Mode::VisualLine => self.config.cursor.visual_line, - Mode::VisualBlock => self.config.cursor.visual_block, - } - }; - self.stdout.queue(cursor_style_for_shape(shape))?; + self.stdout + .queue(cursor_style_for_shape(self.active_cursor_shape()))?; Ok(()) } @@ -2072,9 +2089,11 @@ mod tests { config::Config, editor::display_layout::LineSegment, lsp::{LspManager, Position, Range}, - plugin::{Decoration, DecorationAnchor}, + plugin::{Decoration, DecorationAnchor, PanelConfig, PanelSide, TextPanelComposerConfig}, theme::Theme, + ui::AgentComposer, }; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; fn diagnostic(message: &str) -> Diagnostic { Diagnostic { @@ -2128,6 +2147,146 @@ mod tests { } } + #[test] + fn floating_agent_composer_uses_the_configured_cursor_for_its_own_mode() { + let mut config = Config::default(); + config.cursor.normal = CursorShape::SteadyBlock; + config.cursor.insert = CursorShape::BlinkingBar; + config.cursor.visual = CursorShape::BlinkingUnderscore; + config.cursor.waiting = CursorShape::SteadyUnderscore; + let lsp = Box::new(LspManager::new(config.lsp.clone())); + let buffer = Buffer::new(None, "background editor".to_string()); + let mut editor = + Editor::with_size(lsp, 40, 12, config, Theme::default(), vec![buffer]).unwrap(); + + assert_eq!(editor.mode, Mode::Normal); + assert_eq!(editor.active_cursor_shape(), CursorShape::SteadyBlock); + + let composer = AgentComposer::new( + &editor, + Some("Agent prompt".to_string()), + 802, + "draft".to_string(), + Vec::new(), + "agent".to_string(), + ); + editor.current_dialog = Some(Box::new(composer)); + + assert_eq!(editor.mode, Mode::Normal); + assert_eq!(editor.active_cursor_shape(), CursorShape::BlinkingBar); + assert!(editor.render_cursor_position().is_some()); + + editor + .current_dialog + .as_mut() + .unwrap() + .handle_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); + assert_eq!(editor.active_cursor_shape(), CursorShape::SteadyBlock); + + editor + .current_dialog + .as_mut() + .unwrap() + .handle_event(&Event::Key(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))); + assert_eq!( + editor.active_cursor_shape(), + CursorShape::BlinkingUnderscore + ); + + editor + .current_dialog + .as_mut() + .unwrap() + .handle_event(&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); + editor + .current_dialog + .as_mut() + .unwrap() + .handle_event(&Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))); + assert_eq!(editor.active_cursor_shape(), CursorShape::BlinkingBar); + + editor.waiting_key_action = Some(KeyAction::None); + assert_eq!(editor.active_cursor_shape(), CursorShape::SteadyUnderscore); + editor.waiting_key_action = None; + + editor.current_dialog = None; + assert_eq!(editor.active_cursor_shape(), CursorShape::SteadyBlock); + } + + #[test] + fn focused_agent_conversation_uses_a_real_mode_aware_terminal_cursor() { + let mut config = Config::default(); + config.cursor.normal = CursorShape::SteadyBlock; + config.cursor.insert = CursorShape::SteadyBar; + let lsp = Box::new(LspManager::new(config.lsp.clone())); + let buffer = Buffer::new(None, "background editor".to_string()); + let mut editor = + Editor::with_size(lsp, 40, 12, config, Theme::default(), vec![buffer]).unwrap(); + editor.test_create_text_panel( + "agent", + PanelConfig { + side: PanelSide::Right, + width: 20, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask".to_string(), + rows: 2, + }), + surface: None, + border: None, + header_actions: Vec::new(), + }, + ); + let editor_cursor = editor.render_cursor_position(); + + assert!(editor.uses_synthetic_block_cursor()); + assert!(editor.test_focus_panel("agent")); + let transcript_cursor = editor.render_cursor_position(); + assert!(transcript_cursor.is_some()); + assert_ne!(transcript_cursor, editor_cursor); + assert_eq!(editor.active_cursor_shape(), CursorShape::SteadyBlock); + assert!(!editor.uses_synthetic_block_cursor()); + + assert!(editor.test_focus_text_panel_composer("agent")); + let composer_cursor = editor.render_cursor_position(); + assert!(composer_cursor.is_some()); + assert_ne!(composer_cursor, transcript_cursor); + assert_eq!(editor.active_cursor_shape(), CursorShape::SteadyBar); + assert!(!editor.uses_synthetic_block_cursor()); + + assert!(editor + .panel_manager + .handle_focused_text_input( + &Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + 40, + ) + .is_some()); + assert!(editor.render_cursor_position().is_some()); + assert_eq!(editor.active_cursor_shape(), CursorShape::SteadyBlock); + assert!(!editor.uses_synthetic_block_cursor()); + + assert!(editor + .panel_manager + .handle_focused_text_input( + &Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), + 40, + ) + .is_some()); + assert!(editor.render_cursor_position().is_some()); + assert_eq!(editor.active_cursor_shape(), CursorShape::SteadyBlock); + assert!(!editor.uses_synthetic_block_cursor()); + + editor.panel_manager.focus_editor(); + assert_eq!(editor.render_cursor_position(), editor_cursor); + assert!(editor.uses_synthetic_block_cursor()); + } + #[test] fn diagnostic_row_fits_available_display_width() { let diagnostic = diagnostic("wide 👋 diagnostic 世界 message"); diff --git a/src/main.rs b/src/main.rs index 81dd751a..bcfd00a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -849,6 +849,78 @@ mod tests { ); } + #[test] + fn detached_key_input_preserves_modified_enter_for_agent_composition() { + for (modifiers, expected) in [ + (event::KeyModifiers::CONTROL, vec![KeyModifier::Control]), + (event::KeyModifiers::ALT, vec![KeyModifier::Alt]), + (event::KeyModifiers::SHIFT, vec![KeyModifier::Shift]), + ( + event::KeyModifiers::CONTROL | event::KeyModifiers::ALT, + vec![KeyModifier::Control, KeyModifier::Alt], + ), + ] { + assert_eq!( + detached_key_input(event::KeyEvent::new(event::KeyCode::Enter, modifiers)), + Some(DetachedInput::Key { + code: DetachedKeyCode::Enter, + modifiers: expected, + }) + ); + } + } + + #[test] + fn detached_key_input_preserves_modified_csi_u_line_endings() { + let cases: &[(event::KeyModifiers, &[KeyModifier])] = &[ + (event::KeyModifiers::CONTROL, &[KeyModifier::Control]), + (event::KeyModifiers::ALT, &[KeyModifier::Alt]), + ( + event::KeyModifiers::CONTROL | event::KeyModifiers::SHIFT, + &[KeyModifier::Control, KeyModifier::Shift], + ), + ( + event::KeyModifiers::ALT | event::KeyModifiers::SHIFT, + &[KeyModifier::Alt, KeyModifier::Shift], + ), + ( + event::KeyModifiers::CONTROL | event::KeyModifiers::ALT, + &[KeyModifier::Control, KeyModifier::Alt], + ), + ]; + + for character in ['\n', '\r'] { + for &(modifiers, expected_modifiers) in cases { + for kind in [event::KeyEventKind::Press, event::KeyEventKind::Repeat] { + let input = detached_key_input(event::KeyEvent::new_with_kind( + event::KeyCode::Char(character), + modifiers, + kind, + )); + + assert_eq!( + input, + Some(DetachedInput::Key { + code: DetachedKeyCode::Character(character), + modifiers: expected_modifiers.to_vec(), + }), + "detached input must preserve {character:?} with {modifiers:?} on {kind:?}", + ); + } + + assert_eq!( + detached_key_input(event::KeyEvent::new_with_kind( + event::KeyCode::Char(character), + modifiers, + event::KeyEventKind::Release, + )), + None, + "released modified {character:?} must not be forwarded", + ); + } + } + } + #[test] fn detached_resize_drops_rows_below_the_new_terminal_height() { let mut rows = (0..5) diff --git a/src/plugin/api.rs b/src/plugin/api.rs index 433047fb..99504f7b 100644 --- a/src/plugin/api.rs +++ b/src/plugin/api.rs @@ -496,6 +496,27 @@ mod tests { .expect("agent archive must be present in the host API schema"); assert_eq!(archive.signature, "(session_id: String)"); assert_eq!(archive.introduced, "0.2.0"); + + for (name, signature) in [ + ("AgentResumeSession", "(session_id: String, cwd: String)"), + ("AgentSteer", "(session_id: String, text: String)"), + ("AgentListModels", "(session_id: String)"), + ("AgentListSessions", "(session_id: String, cwd: String)"), + ( + "AgentSetModel", + "(session_id: String, model: String, reasoning_effort?: String)", + ), + ("AgentSetReasoningEffort", "(effort: String)"), + ("SetAgentPosition", "(position: String)"), + ] { + let call = HOST_API + .calls + .iter() + .find(|call| call.kind == "execute" && call.name == name) + .unwrap_or_else(|| panic!("{name} must be present in the host API schema")); + assert_eq!(call.signature, signature); + assert_eq!(call.introduced, "0.4.1"); + } } #[test] diff --git a/src/plugin/host_api.json b/src/plugin/host_api.json index a7c0db12..4f136156 100644 --- a/src/plugin/host_api.json +++ b/src/plugin/host_api.json @@ -1,5 +1,5 @@ { - "version": "0.4.0", + "version": "0.4.1", "calls": [ { "name": "Print", "kind": "execute", "signature": "(message: String)", "introduced": "0.1.0" }, { "name": "FilePicker", "kind": "execute", "signature": "()", "introduced": "0.1.0" }, @@ -15,8 +15,14 @@ { "name": "PreviewTheme", "kind": "execute", "signature": "(theme: String)", "introduced": "0.1.0" }, { "name": "SetTheme", "kind": "execute", "signature": "(theme: String)", "introduced": "0.1.0" }, { "name": "AgentNewSession", "kind": "execute", "signature": "(cwd: String)", "introduced": "0.1.0" }, + { "name": "AgentResumeSession", "kind": "execute", "signature": "(session_id: String, cwd: String)", "introduced": "0.4.1" }, { "name": "AgentPrompt", "kind": "execute", "signature": "(session_id: String, text: String)", "introduced": "0.1.0" }, { "name": "AgentPromptWithContext", "kind": "execute", "signature": "(session_id: String, text: String, context: Json)", "introduced": "0.2.0" }, + { "name": "AgentSteer", "kind": "execute", "signature": "(session_id: String, text: String)", "introduced": "0.4.1" }, + { "name": "AgentListModels", "kind": "execute", "signature": "(session_id: String)", "introduced": "0.4.1" }, + { "name": "AgentListSessions", "kind": "execute", "signature": "(session_id: String, cwd: String)", "introduced": "0.4.1" }, + { "name": "AgentSetModel", "kind": "execute", "signature": "(session_id: String, model: String, reasoning_effort?: String)", "introduced": "0.4.1" }, + { "name": "AgentSetReasoningEffort", "kind": "execute", "signature": "(effort: String)", "introduced": "0.4.1" }, { "name": "AgentCancel", "kind": "execute", "signature": "(session_id: String)", "introduced": "0.1.0" }, { "name": "AgentCloseSession", "kind": "execute", "signature": "(session_id: String)", "introduced": "0.2.0" }, { "name": "AgentArchiveSession", "kind": "execute", "signature": "(session_id: String)", "introduced": "0.2.0" }, @@ -67,6 +73,7 @@ { "name": "FocusPanel", "kind": "execute", "signature": "(id: String)", "introduced": "0.1.0" }, { "name": "FocusEditor", "kind": "execute", "signature": "()", "introduced": "0.1.0" }, { "name": "SetPanelVisible", "kind": "execute", "signature": "(id: String, visible: bool)", "introduced": "0.2.0" }, + { "name": "SetAgentPosition", "kind": "execute", "signature": "(position: String)", "introduced": "0.4.1" }, { "name": "ClosePanel", "kind": "execute", "signature": "(id: String)", "introduced": "0.1.0" }, { "name": "SpawnProcess", "kind": "execute", "signature": "(options: ProcessOptions)", "introduced": "0.1.0" }, { "name": "KillProcess", "kind": "execute", "signature": "(process_id: String)", "introduced": "0.1.0" }, diff --git a/src/plugin/panel.rs b/src/plugin/panel.rs index 1c8c48de..f64c7b6f 100644 --- a/src/plugin/panel.rs +++ b/src/plugin/panel.rs @@ -13,26 +13,29 @@ use std::{collections::HashMap, time::Instant}; use crossterm::event::{Event, KeyCode, KeyModifiers}; use serde::{Deserialize, Serialize}; +use unicode_segmentation::UnicodeSegmentation; use super::markdown::{ - render_markdown_lines, wrap_plain_text, RenderedTextLine, RenderedTextSpan, TextPanelSpanStyle, + render_markdown_lines, render_markdown_lines_with_highlighter, wrap_plain_text, + RenderedTextLine, RenderedTextSpan, TextPanelSpanStyle, }; use super::text_link::{TextPanelLink, TextPanelLinkTarget}; use crate::{ editor::{render_buffer::RenderBuffer, Point}, + highlighter::Highlighter, theme::{SelectionForegroundPriority, Style, Theme, ThemeStyleSpec}, - ui::{normalize_newlines, wrap_text}, - unicode_utils::{ - display_width, fit_display_width, grapheme_len, grapheme_to_byte, truncate_display_width, - }, + ui::{wrap_text, ModalComposer, ModalComposerMode, ModalComposerOutcome}, + unicode_utils::{display_width, fit_display_width, truncate_display_width}, }; -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PanelSide { #[default] Left, Right, + Top, + Bottom, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -77,6 +80,10 @@ fn default_composer_rows() -> usize { } fn effective_panel_width(config: &PanelConfig, terminal_width: usize) -> usize { + if matches!(config.side, PanelSide::Top | PanelSide::Bottom) { + return terminal_width; + } + let max_width = if config.composer.is_some() { terminal_width.saturating_sub(11).max(1) } else { @@ -85,6 +92,14 @@ fn effective_panel_width(config: &PanelConfig, terminal_width: usize) -> usize { config.width.min(max_width) } +fn effective_panel_height(config: &PanelConfig, available_height: usize) -> usize { + if matches!(config.side, PanelSide::Top | PanelSide::Bottom) { + config.width.min(available_height) + } else { + available_height + } +} + /// Optional persistent input area rendered at the bottom of a text panel. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -195,12 +210,28 @@ pub struct TextPanelStatus { pub stream: bool, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum TextPanelFocus { + #[default] + Conversation, + Composer, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct TranscriptCursor { + row: usize, + grapheme: usize, + preferred_column: usize, +} + pub struct TextPanel { pub id: String, pub config: PanelConfig, pub blocks: Vec, pub scroll: usize, pub follow_tail: bool, + focus: TextPanelFocus, + transcript_cursor: TranscriptCursor, composer: Option, status: Option, busy_since: Option, @@ -208,11 +239,16 @@ pub struct TextPanel { } const TEXT_PANEL_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const TEXT_PANEL_ASCII_SPINNER_FRAMES: [&str; 4] = ["|", "/", "-", "\\"]; const TEXT_PANEL_SPINNER_INTERVAL_MS: u64 = 120; -fn spinner_frame(elapsed_ms: u64) -> &'static str { +fn spinner_frame(elapsed_ms: u64, use_ascii: bool) -> &'static str { let index = (elapsed_ms / TEXT_PANEL_SPINNER_INTERVAL_MS) as usize; - TEXT_PANEL_SPINNER_FRAMES[index % TEXT_PANEL_SPINNER_FRAMES.len()] + if use_ascii { + TEXT_PANEL_ASCII_SPINNER_FRAMES[index % TEXT_PANEL_ASCII_SPINNER_FRAMES.len()] + } else { + TEXT_PANEL_SPINNER_FRAMES[index % TEXT_PANEL_SPINNER_FRAMES.len()] + } } fn format_elapsed(seconds: u64) -> String { @@ -223,131 +259,22 @@ fn format_elapsed(seconds: u64) -> String { } } -const MAX_COMPOSER_BYTES: usize = 128 * 1024; - struct TextPanelComposer { config: TextPanelComposerConfig, - draft: String, - cursor: usize, + composer: ModalComposer, focused: bool, enabled: bool, status: Option, - validation: Option<&'static str>, - history: Vec, - history_index: Option, - saved_draft: Option, } impl TextPanelComposer { fn new(config: TextPanelComposerConfig) -> Self { Self { config, - draft: String::new(), - cursor: 0, + composer: ModalComposer::new("", Vec::new()), focused: false, enabled: true, status: None, - validation: None, - history: Vec::new(), - history_index: None, - saved_draft: None, - } - } - - fn insert(&mut self, text: &str) { - let text = normalize_newlines(text); - if text.len() > MAX_COMPOSER_BYTES.saturating_sub(self.draft.len()) { - self.validation = Some("Prompt exceeds 128 KiB"); - return; - } - let offset = grapheme_to_byte(&self.draft, self.cursor); - self.draft.insert_str(offset, &text); - self.cursor = grapheme_len(&self.draft[..offset + text.len()]); - self.validation = None; - } - - fn backspace(&mut self) { - if self.cursor == 0 { - return; - } - let start = grapheme_to_byte(&self.draft, self.cursor - 1); - let end = grapheme_to_byte(&self.draft, self.cursor); - self.draft.replace_range(start..end, ""); - self.cursor -= 1; - self.validation = None; - } - - fn delete(&mut self) { - if self.cursor >= grapheme_len(&self.draft) { - return; - } - let start = grapheme_to_byte(&self.draft, self.cursor); - let end = grapheme_to_byte(&self.draft, self.cursor + 1); - self.draft.replace_range(start..end, ""); - self.validation = None; - } - - fn take_submission(&mut self) -> Option { - if self.draft.trim().is_empty() { - self.validation = Some("Prompt is empty"); - return None; - } - self.cursor = 0; - self.validation = None; - let text = std::mem::take(&mut self.draft); - self.history.retain(|entry| entry != &text); - self.history.insert(0, text.clone()); - self.history.truncate(50); - self.history_index = None; - self.saved_draft = None; - Some(text) - } - - fn history_previous(&mut self) { - if self.history.is_empty() { - return; - } - let index = self.history_index.map_or(0, |index| { - index.saturating_add(1).min(self.history.len() - 1) - }); - if self.history_index.is_none() { - self.saved_draft = Some(self.draft.clone()); - } - self.history_index = Some(index); - self.draft = self.history[index].clone(); - self.cursor = grapheme_len(&self.draft); - } - - fn history_next(&mut self) { - let Some(index) = self.history_index else { - return; - }; - if index == 0 { - self.history_index = None; - self.draft = self.saved_draft.take().unwrap_or_default(); - } else { - self.history_index = Some(index - 1); - self.draft = self.history[index - 1].clone(); - } - self.cursor = grapheme_len(&self.draft); - } - - fn move_vertical(&mut self, delta: isize, width: usize) { - let wrapped = wrap_text(&self.draft, width.max(1)); - let (row, column) = wrapped - .positions - .get(self.cursor) - .copied() - .unwrap_or_default(); - let target = row.saturating_add_signed(delta); - if let Some((index, _)) = wrapped - .positions - .iter() - .enumerate() - .filter(|(_, position)| position.0 == target) - .min_by_key(|(_, position)| position.1.abs_diff(column)) - { - self.cursor = index; } } } @@ -361,6 +288,8 @@ impl TextPanel { blocks: Vec::new(), scroll: 0, follow_tail: true, + focus: TextPanelFocus::Conversation, + transcript_cursor: TranscriptCursor::default(), composer, status: None, busy_since: None, @@ -377,6 +306,34 @@ impl TextPanel { self.status = status; } + fn focus_conversation(&mut self) { + self.focus = TextPanelFocus::Conversation; + if let Some(composer) = self.composer.as_mut() { + composer.focused = false; + } + } + + fn focus_composer(&mut self) -> bool { + let Some(composer) = self.composer.as_mut() else { + return false; + }; + if !composer.enabled { + return false; + } + + composer.focused = true; + self.focus = TextPanelFocus::Composer; + true + } + + fn composer_is_focused(&self) -> bool { + self.focus == TextPanelFocus::Composer + && self + .composer + .as_ref() + .is_some_and(|composer| composer.focused && composer.enabled) + } + fn status_height(&self) -> usize { usize::from(self.status.is_some()) } @@ -428,25 +385,140 @@ impl TextPanel { let max_scroll = self.max_scroll(panel_height, panel_width); self.scroll = self.scroll.saturating_add_signed(delta).min(max_scroll); self.follow_tail = self.scroll == max_scroll; + self.sync_transcript_cursor(panel_height, panel_width); } fn page_scroll(&mut self, delta: isize, panel_height: usize, panel_width: usize) { - let page = self.visible_rows(panel_height).max(1) as isize; - self.move_scroll(delta.saturating_mul(page), panel_height, panel_width); + let page = isize::try_from(self.visible_rows(panel_height)).unwrap_or(isize::MAX); + self.move_transcript_cursor(delta.saturating_mul(page), panel_height, panel_width); } fn scroll_to_top(&mut self) { self.scroll = 0; self.follow_tail = false; + self.transcript_cursor = TranscriptCursor::default(); } fn scroll_to_bottom(&mut self, panel_height: usize, panel_width: usize) { self.scroll = self.max_scroll(panel_height, panel_width); self.follow_tail = true; + self.sync_transcript_cursor(panel_height, panel_width); } fn clamp_scroll(&mut self, panel_height: usize, panel_width: usize) { self.scroll = self.scroll.min(self.max_scroll(panel_height, panel_width)); + self.sync_transcript_cursor(panel_height, panel_width); + } + + fn sync_transcript_cursor(&mut self, panel_height: usize, panel_width: usize) { + let lines = self.rendered_lines(panel_width.max(1)); + let visible_rows = self.visible_rows(panel_height); + let max_scroll = lines.len().saturating_sub(visible_rows); + self.scroll = self.scroll.min(max_scroll); + + if lines.is_empty() { + self.transcript_cursor = TranscriptCursor::default(); + self.scroll = 0; + return; + } + + let last_row = lines.len().saturating_sub(1); + if self.follow_tail { + self.scroll = max_scroll; + self.transcript_cursor.row = last_row; + } else { + let last_visible_row = self + .scroll + .saturating_add(visible_rows.saturating_sub(1)) + .min(last_row); + self.transcript_cursor.row = self + .transcript_cursor + .row + .min(last_row) + .clamp(self.scroll, last_visible_row); + } + + self.transcript_cursor.grapheme = transcript_grapheme_at_column( + &lines[self.transcript_cursor.row], + self.transcript_cursor.preferred_column, + ); + } + + fn set_transcript_cursor( + &mut self, + row: usize, + column: usize, + panel_height: usize, + panel_width: usize, + ) { + let lines = self.rendered_lines(panel_width.max(1)); + if lines.is_empty() { + self.transcript_cursor = TranscriptCursor::default(); + self.scroll = 0; + self.follow_tail = true; + return; + } + + let visible_rows = self.visible_rows(panel_height); + let last_row = lines.len().saturating_sub(1); + let row = row.min(last_row); + let max_scroll = lines.len().saturating_sub(visible_rows); + self.scroll = self.scroll.min(max_scroll); + if row < self.scroll { + self.scroll = row; + } else if row >= self.scroll.saturating_add(visible_rows) { + self.scroll = row.saturating_sub(visible_rows.saturating_sub(1)); + } + + self.transcript_cursor = TranscriptCursor { + row, + grapheme: transcript_grapheme_at_column(&lines[row], column), + preferred_column: column, + }; + self.follow_tail = row == last_row; + if self.follow_tail { + self.scroll = max_scroll; + } + } + + fn move_transcript_cursor(&mut self, delta: isize, panel_height: usize, panel_width: usize) { + self.sync_transcript_cursor(panel_height, panel_width); + let row = self.transcript_cursor.row.saturating_add_signed(delta); + self.set_transcript_cursor( + row, + self.transcript_cursor.preferred_column, + panel_height, + panel_width, + ); + self.selected_link = None; + } + + fn move_transcript_horizontally( + &mut self, + delta: isize, + panel_height: usize, + panel_width: usize, + ) { + self.sync_transcript_cursor(panel_height, panel_width); + let lines = self.rendered_lines(panel_width.max(1)); + let Some(line) = lines.get(self.transcript_cursor.row) else { + return; + }; + + let last_grapheme = line + .spans + .iter() + .flat_map(|span| span.text.graphemes(true)) + .count() + .saturating_sub(1); + self.transcript_cursor.grapheme = self + .transcript_cursor + .grapheme + .saturating_add_signed(delta) + .min(last_grapheme); + self.transcript_cursor.preferred_column = + transcript_grapheme_column(line, self.transcript_cursor.grapheme); + self.selected_link = None; } fn max_scroll(&self, panel_height: usize, panel_width: usize) -> usize { @@ -523,14 +595,23 @@ impl TextPanel { (None, false) => links.len() - 1, }; let (link, line) = &links[index]; - self.selected_link = Some(link.id); + let selected_id = link.id; + let line_index = *line; + let column = self + .rendered_lines(width) + .get(line_index) + .map(|line| { + line.spans + .iter() + .take_while(|span| span.link.as_ref().is_none_or(|link| link.id != selected_id)) + .map(|span| display_width(&span.text)) + .sum() + }) + .unwrap_or_default(); + + self.set_transcript_cursor(line_index, column, panel_height, width); + self.selected_link = Some(selected_id); self.follow_tail = false; - let visible_rows = self.visible_rows(panel_height); - if *line < self.scroll { - self.scroll = *line; - } else if *line >= self.scroll.saturating_add(visible_rows) { - self.scroll = line.saturating_sub(visible_rows.saturating_sub(1)); - } true } @@ -543,6 +624,14 @@ impl TextPanel { } fn rendered_lines(&self, width: usize) -> Vec { + self.rendered_lines_with_highlighter(width, None) + } + + fn rendered_lines_with_highlighter( + &self, + width: usize, + mut highlighter: Option<&mut Highlighter>, + ) -> Vec { let mut lines: Vec = Vec::new(); for (block_index, block) in self.blocks.iter().enumerate() { if block.kind == TextPanelBlockKind::User { @@ -565,9 +654,14 @@ impl TextPanel { TextPanelBlockFormat::Plain => { wrap_plain_text(&block.text, content_width, TextPanelSpanStyle::Text) } - TextPanelBlockFormat::Markdown => { - render_markdown_lines(&block.text, content_width) - } + TextPanelBlockFormat::Markdown => match highlighter.as_deref_mut() { + Some(highlighter) => render_markdown_lines_with_highlighter( + &block.text, + content_width, + Some(highlighter), + ), + None => render_markdown_lines(&block.text, content_width), + }, }; if block_lines.is_empty() { block_lines.push(RenderedTextLine::plain( @@ -585,7 +679,14 @@ impl TextPanel { let style = block_style(&block.kind); let mut block_lines = match block.format { TextPanelBlockFormat::Plain => wrap_plain_text(&block.text, width, style), - TextPanelBlockFormat::Markdown => render_markdown_lines(&block.text, width), + TextPanelBlockFormat::Markdown => match highlighter.as_deref_mut() { + Some(highlighter) => render_markdown_lines_with_highlighter( + &block.text, + width, + Some(highlighter), + ), + None => render_markdown_lines(&block.text, width), + }, }; if block_lines.is_empty() { block_lines.push(RenderedTextLine::plain(String::new(), style)); @@ -624,6 +725,36 @@ fn namespace_block_links(lines: &mut [RenderedTextLine], block_index: usize) { } } +fn transcript_grapheme_at_column(line: &RenderedTextLine, target_column: usize) -> usize { + let mut column = 0usize; + let mut last_grapheme = 0usize; + + for (index, grapheme) in line + .spans + .iter() + .flat_map(|span| span.text.graphemes(true)) + .enumerate() + { + let next_column = column.saturating_add(display_width(grapheme)); + if target_column < next_column { + return index; + } + column = next_column; + last_grapheme = index; + } + + last_grapheme +} + +fn transcript_grapheme_column(line: &RenderedTextLine, grapheme_index: usize) -> usize { + line.spans + .iter() + .flat_map(|span| span.text.graphemes(true)) + .take(grapheme_index) + .map(display_width) + .sum() +} + pub struct PluginPanel { pub id: String, pub config: PanelConfig, @@ -775,6 +906,17 @@ pub struct PanelManager { animation_state: Vec<(String, u8, u64)>, } +struct PanelRenderOptions<'a> { + highlighter: Option<&'a mut Highlighter>, + use_ascii: bool, +} + +#[derive(Clone, Copy)] +struct TextPanelRenderStyle<'a> { + theme: &'a Theme, + surface: &'a Style, +} + impl PanelManager { pub fn create_panel(&mut self, id: String, config: PanelConfig) { self.text_panels.remove(&id); @@ -803,6 +945,7 @@ impl PanelManager { ) { if let Some(panel) = self.text_panels.get_mut(id) { let width = effective_panel_width(&panel.config, terminal_width); + let panel_height = effective_panel_height(&panel.config, panel_height); panel.update_blocks(blocks, panel_height, width); } } @@ -817,6 +960,7 @@ impl PanelManager { ) { if let Some(panel) = self.text_panels.get_mut(id) { let width = effective_panel_width(&panel.config, terminal_width); + let panel_height = effective_panel_height(&panel.config, panel_height); panel.append_delta(block_id, delta, panel_height, width); } } @@ -827,6 +971,27 @@ impl PanelManager { } } + /// Moves an existing panel without replacing its rows, draft, or focus. + pub fn update_panel_layout(&mut self, id: &str, side: PanelSide, width: usize) -> bool { + if let Some(panel) = self.panels.get_mut(id) { + if panel.config.side == side && panel.config.width == width { + return false; + } + panel.config.side = side; + panel.config.width = width; + return true; + } + if let Some(panel) = self.text_panels.get_mut(id) { + if panel.config.side == side && panel.config.width == width { + return false; + } + panel.config.side = side; + panel.config.width = width; + return true; + } + false + } + pub fn close_panel(&mut self, id: &str) { self.panels.remove(id); self.text_panels.remove(id); @@ -863,6 +1028,9 @@ impl PanelManager { if self.z_order.iter().any(|panel_id| panel_id == id) && (self.panels.contains_key(id) || self.text_panels.contains_key(id)) { + if let Some(panel) = self.text_panels.get_mut(id) { + panel.focus_conversation(); + } self.focused = Some(id.to_string()); true } else { @@ -878,12 +1046,8 @@ impl PanelManager { pub fn focus_editor(&mut self) { if let Some(id) = self.focused.as_deref() { - if let Some(composer) = self - .text_panels - .get_mut(id) - .and_then(|panel| panel.composer.as_mut()) - { - composer.focused = false; + if let Some(panel) = self.text_panels.get_mut(id) { + panel.focus_conversation(); } } self.focused = None; @@ -897,8 +1061,7 @@ impl PanelManager { self.focused .as_deref() .and_then(|id| self.text_panels.get(id)) - .and_then(|panel| panel.composer.as_ref()) - .is_some_and(|composer| composer.focused && composer.enabled) + .is_some_and(TextPanel::composer_is_focused) } pub fn focused_text_panel_has_composer(&self) -> bool { @@ -956,6 +1119,26 @@ impl PanelManager { .sum() } + /// Number of rows reserved by visible panels docked above the editor. + pub fn reserved_top_height(&self) -> usize { + self.z_order + .iter() + .filter_map(|id| self.panel_config(id)) + .filter(|config| config.side == PanelSide::Top) + .map(|config| config.width.saturating_add(1)) + .sum() + } + + /// Number of rows reserved by visible panels docked below the editor. + pub fn reserved_bottom_height(&self) -> usize { + self.z_order + .iter() + .filter_map(|id| self.panel_config(id)) + .filter(|config| config.side == PanelSide::Bottom) + .map(|config| config.width.saturating_add(1)) + .sum() + } + pub fn handle_focused_key( &mut self, action: &str, @@ -966,9 +1149,10 @@ impl PanelManager { let focused = self.focused.clone()?; if let Some(panel) = self.text_panels.get_mut(&focused) { let width = effective_panel_width(&panel.config, terminal_width); + let panel_height = effective_panel_height(&panel.config, panel_height); match action { - "up" => panel.move_scroll(-1, panel_height, width), - "down" => panel.move_scroll(1, panel_height, width), + "up" => panel.move_transcript_cursor(-1, panel_height, width), + "down" => panel.move_transcript_cursor(1, panel_height, width), "page_up" => { panel.page_scroll(-1, panel_height, width); } @@ -977,6 +1161,12 @@ impl PanelManager { } "top" => panel.scroll_to_top(), "bottom" => panel.scroll_to_bottom(panel_height, width), + "left" | "collapse" => { + panel.move_transcript_horizontally(-1, panel_height, width); + } + "right" | "expand" => { + panel.move_transcript_horizontally(1, panel_height, width); + } _ => {} } return Some(PanelEvent { @@ -988,6 +1178,7 @@ impl PanelManager { }); } let panel = self.panels.get_mut(&focused)?; + let panel_height = effective_panel_height(&panel.config, panel_height); match action { "up" => panel.move_selection(-1, panel_height), @@ -1019,6 +1210,7 @@ impl PanelManager { let action = if delta < 0 { "up" } else { "down" }; if let Some(panel) = self.text_panels.get_mut(id) { let width = effective_panel_width(&panel.config, terminal_width); + let panel_height = effective_panel_height(&panel.config, panel_height); panel.move_scroll(delta, panel_height, width); return Some(PanelEvent { panel_id: panel.id.clone(), @@ -1030,6 +1222,7 @@ impl PanelManager { } let panel = self.panels.get_mut(id)?; + let panel_height = effective_panel_height(&panel.config, panel_height); panel.scroll_view(delta, panel_height, scrolloff); Some(PanelEvent { panel_id: panel.id.clone(), @@ -1053,9 +1246,8 @@ impl PanelManager { return false; }; let width = effective_panel_width(&panel.config, terminal_width); - if let Some(composer) = panel.composer.as_mut() { - composer.focused = false; - } + let panel_height = effective_panel_height(&panel.config, panel_height); + panel.focus_conversation(); panel.select_link(forward, panel_height, width) } @@ -1103,12 +1295,18 @@ impl PanelManager { let end = used.saturating_add(display_width(&span.text)); if column >= used && column < end { let link = span.link.as_ref()?; + let selected_id = link.id; + let target = link.target.clone(); self.focused = Some(placement.id); - panel.selected_link = Some(link.id); - if let Some(composer) = panel.composer.as_mut() { - composer.focused = false; - } - return Some(link.target.clone()); + panel.focus_conversation(); + panel.set_transcript_cursor( + scroll + screen_row - title_rows, + column, + placement.height, + placement.width, + ); + panel.selected_link = Some(selected_id); + return Some(target); } used = end; } @@ -1128,17 +1326,12 @@ impl PanelManager { if !self.z_order.iter().any(|panel_id| panel_id == id) { return false; } - let Some(composer) = self - .text_panels - .get_mut(id) - .and_then(|panel| panel.composer.as_mut()) - else { + let Some(panel) = self.text_panels.get_mut(id) else { return false; }; - if !composer.enabled { + if !panel.focus_composer() { return false; } - composer.focused = true; self.focused = Some(id.to_string()); true } @@ -1149,17 +1342,16 @@ impl PanelManager { enabled: bool, status: Option, ) -> bool { - let Some(composer) = self - .text_panels - .get_mut(id) - .and_then(|panel| panel.composer.as_mut()) - else { + let Some(panel) = self.text_panels.get_mut(id) else { + return false; + }; + let Some(composer) = panel.composer.as_mut() else { return false; }; composer.enabled = enabled; composer.status = status; if !enabled { - composer.focused = false; + panel.focus_conversation(); } true } @@ -1207,81 +1399,54 @@ impl PanelManager { else { return false; }; - composer.draft.clear(); - composer.cursor = 0; - composer.validation = None; - true + composer.composer.set_contents("") } pub fn handle_focused_text_input( &mut self, event: &Event, - terminal_width: usize, + _terminal_width: usize, ) -> Option { let focused = self.focused.clone()?; let panel = self.text_panels.get_mut(&focused)?; - let panel_width = effective_panel_width(&panel.config, terminal_width); - let composer = panel.composer.as_mut()?; - if !composer.focused || !composer.enabled { + if !panel.composer_is_focused() { return None; } - let mut action = "composer_input"; - let mut text = None; - match event { - Event::Paste(pasted) => composer.insert(pasted), - Event::Key(key) => match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { - composer.focused = false; - action = "composer_blur"; - } - (KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::SHIFT) => { - composer.insert("\n"); - } - (KeyCode::Char('j' | 'J'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - composer.insert("\n"); - } - (KeyCode::Char('p' | 'P'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - composer.history_previous(); - } - (KeyCode::Char('n' | 'N'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - composer.history_next(); - } - (KeyCode::Enter, _) => { - text = composer.take_submission(); - action = "submit"; - } - (KeyCode::Backspace, _) => composer.backspace(), - (KeyCode::Delete, _) => composer.delete(), - (KeyCode::Left, _) => composer.cursor = composer.cursor.saturating_sub(1), - (KeyCode::Right, _) => { - composer.cursor = (composer.cursor + 1).min(grapheme_len(&composer.draft)); - } - (KeyCode::Up, _) => { - composer.move_vertical(-1, panel_width.saturating_sub(2)); - } - (KeyCode::Down, _) => { - composer.move_vertical(1, panel_width.saturating_sub(2)); - } - (KeyCode::Home, _) => composer.cursor = 0, - (KeyCode::End, _) => composer.cursor = grapheme_len(&composer.draft), - (KeyCode::Tab, _) => composer.insert("\t"), - (KeyCode::Char(character), modifiers) - if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - composer.insert(&character.to_string()); - } - _ => return None, - }, - _ => return None, + if matches!( + event, + Event::Key(key) + if matches!(key.code, KeyCode::Char('c' | 'C')) + && key.modifiers.contains(KeyModifiers::CONTROL) + ) { + panel.focus_conversation(); + return Some(PanelEvent { + panel_id: panel.id.clone(), + action: "composer_blur".to_string(), + selected_index: panel.scroll, + row: None, + text: None, + }); } + let composer = panel.composer.as_mut()?; + let outcome = match event { + Event::Paste(pasted) => composer.composer.handle_paste(pasted), + Event::Key(key) => composer.composer.handle_key(*key), + _ => return None, + }; + + let (action, text) = match outcome { + ModalComposerOutcome::Submit => match composer.composer.take_submission() { + Some(text) => ("submit", Some(text)), + None => ("composer_input", None), + }, + ModalComposerOutcome::Changed | ModalComposerOutcome::Rejected => { + ("composer_input", None) + } + ModalComposerOutcome::Unhandled => return None, + }; + Some(PanelEvent { panel_id: panel.id.clone(), action: action.to_string(), @@ -1291,6 +1456,24 @@ impl PanelManager { }) } + /// Returns the real Vim mode of the focused conversation or text composer. + pub(crate) fn focused_text_panel_cursor_mode(&self) -> Option { + let id = self.focused.as_deref()?; + let panel = self.text_panels.get(id)?; + + if panel.composer_is_focused() { + let composer = panel.composer.as_ref()?; + Some(match composer.composer.mode() { + ModalComposerMode::Normal => crate::editor::Mode::Normal, + ModalComposerMode::Insert => crate::editor::Mode::Insert, + ModalComposerMode::Visual => crate::editor::Mode::Visual, + }) + } else { + Some(crate::editor::Mode::Normal) + } + } + + /// Returns the visible cursor in the focused conversation or text composer. pub fn focused_text_panel_cursor_position( &self, terminal_width: usize, @@ -1298,49 +1481,106 @@ impl PanelManager { ) -> Option<(usize, usize)> { let id = self.focused.as_deref()?; let panel = self.text_panels.get(id)?; - let composer = panel.composer.as_ref()?; - if !composer.focused || !composer.enabled { - return None; - } let placement = self .panel_placements(terminal_width, terminal_height) .into_iter() .find(|placement| placement.id == id)?; - let content_width = placement.width.saturating_sub(2).max(1); - let wrapped = wrap_text(&composer.draft, content_width); - let (row, column) = wrapped - .positions - .get(composer.cursor) - .copied() - .unwrap_or_default(); - let rows = composer.config.rows.max(1); - let first = row.saturating_sub(rows.saturating_sub(1)); - let top = placement.height.saturating_sub(panel.composer_height()); - Some(( - placement.x.saturating_add(2).saturating_add(column), - top.saturating_add(1) - .saturating_add(row.saturating_sub(first)), - )) - } + if placement.width == 0 || placement.height == 0 { + return None; + } - pub fn focus_panel_at_position( - &mut self, - x: usize, - y: usize, - terminal_width: usize, - terminal_height: usize, - ) -> Option { - let placement = self.panel_at_position(x, y, terminal_width, terminal_height)?; - self.focused = Some(placement.id.clone()); - if let Some(panel) = self.text_panels.get_mut(&placement.id) { - if y == placement.y { - if let Some(action) = text_panel_header_action_at( - &panel.config, - placement.width, - x.saturating_sub(placement.x), - ) { - return Some(PanelEvent { - panel_id: panel.id.clone(), + if panel.composer_is_focused() { + let composer = panel.composer.as_ref()?; + let content_width = placement.width.saturating_sub(2).max(1); + let draft = composer.composer.contents(); + let wrapped = wrap_text(&draft, content_width); + let (row, column) = wrapped + .positions + .get(composer.composer.cursor_grapheme_index()) + .copied() + .unwrap_or_default(); + let rows = composer.config.rows.max(1); + let first = row.saturating_sub(rows.saturating_sub(1)); + let top = placement + .y + .saturating_add(placement.height.saturating_sub(panel.composer_height())); + let x = placement.x.saturating_add(1).saturating_add(column); + let y = top + .saturating_add(1) + .saturating_add(row.saturating_sub(first)); + + return (x < placement.x.saturating_add(placement.width) + && y < placement.y.saturating_add(placement.height)) + .then_some((x, y)); + } + + let title_rows = + usize::from(panel.config.title.is_some() || !panel.config.header_actions.is_empty()); + let visible_rows = placement + .height + .saturating_sub(panel.composer_height()) + .saturating_sub(panel.status_height()) + .saturating_sub(title_rows); + if visible_rows == 0 { + return None; + } + + let lines = panel.rendered_lines(placement.width); + let max_scroll = lines.len().saturating_sub(visible_rows); + let scroll = if panel.follow_tail { + max_scroll + } else { + panel.scroll.min(max_scroll) + }; + let last_visible_row = scroll.saturating_add(visible_rows.saturating_sub(1)); + let row = if lines.is_empty() { + 0 + } else { + let last_row = lines.len().saturating_sub(1); + let anchor = if panel.follow_tail { + last_row + } else { + panel.transcript_cursor.row.min(last_row) + }; + anchor.clamp(scroll, last_visible_row.min(last_row)) + }; + let column = lines + .get(row) + .map(|line| { + let grapheme = + transcript_grapheme_at_column(line, panel.transcript_cursor.preferred_column); + transcript_grapheme_column(line, grapheme) + }) + .unwrap_or_default() + .min(placement.width.saturating_sub(1)); + + Some(( + placement.x.saturating_add(column), + placement + .y + .saturating_add(title_rows) + .saturating_add(row.saturating_sub(scroll)), + )) + } + + pub fn focus_panel_at_position( + &mut self, + x: usize, + y: usize, + terminal_width: usize, + terminal_height: usize, + ) -> Option { + let placement = self.panel_at_position(x, y, terminal_width, terminal_height)?; + self.focused = Some(placement.id.clone()); + if let Some(panel) = self.text_panels.get_mut(&placement.id) { + if y == placement.y { + if let Some(action) = text_panel_header_action_at( + &panel.config, + placement.width, + x.saturating_sub(placement.x), + ) { + return Some(PanelEvent { + panel_id: panel.id.clone(), action: action.to_string(), selected_index: panel.scroll, row: None, @@ -1352,24 +1592,19 @@ impl PanelManager { let composer_top = placement .y .saturating_add(placement.height.saturating_sub(panel.composer_height())); - let action = if y >= composer_top - && panel - .composer - .as_ref() - .is_some_and(|composer| composer.enabled) - { + let action = if y >= composer_top && panel.focus_composer() { if let Some(composer) = panel.composer.as_mut() { - composer.focused = true; let content_width = placement.width.saturating_sub(2).max(1); - let wrapped = wrap_text(&composer.draft, content_width); + let draft = composer.composer.contents(); + let wrapped = wrap_text(&draft, content_width); let cursor_row = wrapped .positions - .get(composer.cursor) + .get(composer.composer.cursor_grapheme_index()) .map_or(0, |position| position.0); let rows = composer.config.rows.max(1); let first = cursor_row.saturating_sub(rows.saturating_sub(1)); let row = first.saturating_add(y.saturating_sub(composer_top + 1)); - let column = x.saturating_sub(placement.x + 2); + let column = x.saturating_sub(placement.x.saturating_add(1)); if let Some((index, _)) = wrapped .positions .iter() @@ -1377,14 +1612,39 @@ impl PanelManager { .filter(|(_, position)| position.0 == row) .min_by_key(|(_, position)| position.1.abs_diff(column)) { - composer.cursor = index; + composer.composer.set_cursor_grapheme_index(index); } } "composer_focus" } else { - if let Some(composer) = panel.composer.as_mut() { - composer.focused = false; + panel.focus_conversation(); + let title_rows = usize::from( + panel.config.title.is_some() || !panel.config.header_actions.is_empty(), + ); + let visible_rows = placement + .height + .saturating_sub(panel.composer_height()) + .saturating_sub(panel.status_height()) + .saturating_sub(title_rows); + if visible_rows > 0 { + let lines = panel.rendered_lines(placement.width); + let max_scroll = lines.len().saturating_sub(visible_rows); + let scroll = if panel.follow_tail { + max_scroll + } else { + panel.scroll.min(max_scroll) + }; + let offset = y + .saturating_sub(placement.y.saturating_add(title_rows)) + .min(visible_rows.saturating_sub(1)); + panel.set_transcript_cursor( + scroll.saturating_add(offset), + x.saturating_sub(placement.x), + placement.height, + placement.width, + ); } + panel.selected_link = None; "select" }; return Some(PanelEvent { @@ -1437,76 +1697,149 @@ impl PanelManager { let mut placements = Vec::new(); let mut left_x: usize = 0; let mut right_x = terminal_width; - let height = terminal_height.saturating_sub(2); + let content_height = terminal_height.saturating_sub(2); + let reserved_top = self.reserved_top_height().min(content_height); + let reserved_bottom = self + .reserved_bottom_height() + .min(content_height.saturating_sub(reserved_top)); + let side_height = content_height + .saturating_sub(reserved_top) + .saturating_sub(reserved_bottom); + let mut top_y = 0usize; + let mut bottom_y = content_height; for id in &self.z_order { let Some(config) = self.panel_config(id) else { continue; }; - let width = effective_panel_width(config, terminal_width); - let x = match config.side { + let (x, y, width, height) = match config.side { PanelSide::Left => { + let width = effective_panel_width(config, terminal_width) + .min(right_x.saturating_sub(left_x)); let x = left_x; left_x = left_x.saturating_add(width.saturating_add(1)); - x + (x, reserved_top, width, side_height) } PanelSide::Right => { + let width = effective_panel_width(config, terminal_width) + .min(right_x.saturating_sub(left_x)); right_x = right_x.saturating_sub(width); let x = right_x; right_x = right_x.saturating_sub(1); - x + (x, reserved_top, width, side_height) + } + PanelSide::Top => { + let height = config.width.min(reserved_top.saturating_sub(top_y)); + let y = top_y; + top_y = top_y.saturating_add(height.saturating_add(1)); + (0, y, terminal_width, height) + } + PanelSide::Bottom => { + let available = + bottom_y.saturating_sub(content_height.saturating_sub(reserved_bottom)); + let height = config.width.min(available); + let y = bottom_y.saturating_sub(height); + bottom_y = y.saturating_sub(1); + (0, y, terminal_width, height) } }; - placements.push(PanelPlacement { - id: id.clone(), - x, - y: 0, - width, - height, - }); + if width > 0 && height > 0 { + placements.push(PanelPlacement { + id: id.clone(), + x, + y, + width, + height, + }); + } } placements } pub fn render(&self, buffer: &mut RenderBuffer, theme: &Theme) { - let mut left_x: usize = 0; - let mut right_x = buffer.width; + self.render_with_options( + buffer, + theme, + PanelRenderOptions { + highlighter: None, + use_ascii: false, + }, + ); + } - for id in &self.z_order { - let Some(config) = self.panel_config(id) else { + /// Renders theme-aware docks and syntax-highlighted fenced Markdown. + pub fn render_with_highlighter( + &self, + buffer: &mut RenderBuffer, + theme: &Theme, + highlighter: &mut Highlighter, + use_ascii: bool, + ) { + self.render_with_options( + buffer, + theme, + PanelRenderOptions { + highlighter: Some(highlighter), + use_ascii, + }, + ); + } + + fn render_with_options( + &self, + buffer: &mut RenderBuffer, + theme: &Theme, + mut options: PanelRenderOptions<'_>, + ) { + for placement in self.panel_placements(buffer.width, buffer.height) { + let Some(config) = self.panel_config(&placement.id) else { continue; }; - - let width = effective_panel_width(config, buffer.width); - let (x, separator_x) = match config.side { - PanelSide::Left => { - let x = left_x; - left_x = left_x.saturating_add(width.saturating_add(1)); - (x, x.checked_add(width)) - } - PanelSide::Right => { - right_x = right_x.saturating_sub(width); - let x = right_x; - right_x = right_x.saturating_sub(1); - (x, x.checked_sub(1)) + let position = Point::new(placement.x, placement.y); + let border_style = panel_style(theme, config.border.as_ref()); + let bordered = config.border.is_some() || self.text_panels.contains_key(&placement.id); + let separator = if !bordered { + " " + } else { + match (config.side, options.use_ascii) { + (PanelSide::Left | PanelSide::Right, false) => "│", + (PanelSide::Left | PanelSide::Right, true) => "|", + (PanelSide::Top | PanelSide::Bottom, false) => "─", + (PanelSide::Top | PanelSide::Bottom, true) => "-", } }; + render_panel_separator( + buffer, + position, + placement.width, + placement.height, + &config.side, + &border_style, + separator, + ); - if let Some(separator_x) = separator_x.filter(|x| *x < buffer.width) { - let border_style = panel_style(theme, config.border.as_ref()); - let separator = if config.border.is_some() { "│" } else { " " }; - for y in 0..buffer.height.saturating_sub(2) { - buffer.set_text(separator_x, y, separator, &border_style); - } - } - - if let Some(panel) = self.panels.get(id) { - render_panel(buffer, panel, Point::new(x, 0), width, theme); - } else if let Some(panel) = self.text_panels.get(id) { - render_text_panel(buffer, panel, Point::new(x, 0), width, theme); + if let Some(panel) = self.panels.get(&placement.id) { + render_panel( + buffer, + panel, + position, + placement.width, + placement.height, + theme, + ); + } else if let Some(panel) = self.text_panels.get(&placement.id) { + render_text_panel( + buffer, + panel, + position, + placement.width, + placement.height, + theme, + &mut options, + ); } } } @@ -1533,13 +1866,13 @@ fn render_panel( panel: &PluginPanel, position: Point, width: usize, + height: usize, theme: &Theme, ) { - if width == 0 || buffer.height <= 2 { + if width == 0 || height == 0 { return; } - let height = buffer.height.saturating_sub(2); let surface_style = panel_style(theme, panel.config.surface.as_ref()); let selection_style = theme.list_selection_style(); let selected_style = theme.selected_style( @@ -1553,13 +1886,18 @@ fn render_panel( }; for y in 0..height { - buffer.set_text(position.x, y, &" ".repeat(width), &surface_style); + buffer.set_text( + position.x, + position.y.saturating_add(y), + &" ".repeat(width), + &surface_style, + ); } if let Some(title) = &panel.config.title { buffer.set_text( position.x, - 0, + position.y, &fit_display_width(title, width), &title_style, ); @@ -1574,7 +1912,7 @@ fn render_panel( .take(visible_rows) .enumerate() { - let y = rows_start + screen_row; + let y = position.y.saturating_add(rows_start + screen_row); let index = panel.scroll + screen_row; let selected = index == panel.selected; if selected { @@ -1598,15 +1936,26 @@ fn render_text_panel( panel: &TextPanel, position: Point, width: usize, + height: usize, theme: &Theme, + options: &mut PanelRenderOptions<'_>, ) { - if width == 0 || buffer.height <= 2 { + if width == 0 || height == 0 { return; } - let height = buffer.height.saturating_sub(2); + let surface_style = panel_style(theme, panel.config.surface.as_ref()); + let render_style = TextPanelRenderStyle { + theme, + surface: &surface_style, + }; for y in 0..height { - buffer.set_text(position.x, y, &" ".repeat(width), &theme.style); + buffer.set_text( + position.x, + position.y.saturating_add(y), + &" ".repeat(width), + &surface_style, + ); } let header_actions = text_panel_header_actions(&panel.config, width); @@ -1617,20 +1966,22 @@ fn render_text_panel( if let Some(title) = &panel.config.title { let title_style = Style { bold: true, - ..theme.style.clone() + ..surface_style.clone() }; buffer.set_text( position.x, - 0, + position.y, &fit_display_width(title, title_width), &title_style, ); } + let muted_style = theme.ui_style.muted.with_bg(surface_style.bg); + let action_style = theme.ui_style.picker_prompt.with_bg(surface_style.bg); for (start, _, label) in header_actions { let x = position.x + start; - buffer.set_text(x, 0, "[", &theme.ui_style.muted); - buffer.set_text(x + 1, 0, label, &theme.ui_style.picker_prompt); - buffer.set_text(x + 1 + display_width(label), 0, "]", &theme.ui_style.muted); + buffer.set_text(x, position.y, "[", &muted_style); + buffer.set_text(x + 1, position.y, label, &action_style); + buffer.set_text(x + 1 + display_width(label), position.y, "]", &muted_style); } let composer_height = panel.composer_height(); @@ -1639,7 +1990,7 @@ fn render_text_panel( .saturating_sub(composer_height) .saturating_sub(status_height); let visible_rows = content_height.saturating_sub(title_rows); - let lines = panel.rendered_lines(width); + let lines = panel.rendered_lines_with_highlighter(width, options.highlighter.as_deref_mut()); let max_scroll = lines.len().saturating_sub(visible_rows); let scroll = if panel.follow_tail { max_scroll @@ -1650,23 +2001,23 @@ fn render_text_panel( render_text_spans( buffer, position.x, - title_rows + offset, + position.y.saturating_add(title_rows + offset), width, line, panel.selected_link, - theme, + render_style, ); } - if let Some(status) = &panel.status { + if panel.status.is_some() { render_text_panel_status( buffer, panel, - status, position, width, content_height, - theme, + render_style, + options.use_ascii, ); } @@ -1677,18 +2028,63 @@ fn render_text_panel( position, width, content_height + status_height, - theme, + render_style, + options.use_ascii, ); } +} - render_panel_separator( - buffer, - position, - width, - height, - &panel.config.side, - &theme.style, - ); +fn text_panel_composer_hints(composer: &TextPanelComposer, width: usize) -> String { + let (mode, candidates): (&str, [&str; 4]) = if composer.focused && composer.enabled { + match composer.composer.mode() { + ModalComposerMode::Insert => ( + "INSERT", + [ + "Enter newline | Ctrl+Enter send | Alt+Enter send | Esc normal | ^P/^N history", + "Ctrl+Enter send | Enter newline | Esc normal", + "Ctrl+Enter send", + "C-Enter send", + ], + ), + ModalComposerMode::Normal => ( + "NORMAL", + [ + "Ctrl+Enter/Alt+Enter send | Enter send | i edit | u undo | ^P/^N history", + "Ctrl+Enter send | Enter send | i edit", + "Ctrl+Enter send | i edit", + "C-Enter send", + ], + ), + ModalComposerMode::Visual => ( + "VISUAL", + [ + "Ctrl+Enter send | hjkl select | d/c/y operate | Esc normal", + "Ctrl+Enter send | hjkl select | Esc normal", + "Ctrl+Enter send", + "C-Enter send", + ], + ), + } + } else { + ( + "READ", + [ + "j/k navigate | i/a edit | Esc editor | x clear | N new", + "j/k navigate | i/a edit | Esc editor", + "j/k | i edit | Esc", + "i edit", + ], + ) + }; + + for shortcuts in candidates { + let hint = format!("{mode} {shortcuts}"); + if display_width(&hint) <= width { + return hint; + } + } + + mode.to_string() } fn render_text_panel_composer( @@ -1697,25 +2093,31 @@ fn render_text_panel_composer( position: Point, width: usize, top: usize, - theme: &Theme, + render_style: TextPanelRenderStyle<'_>, + use_ascii: bool, ) { if width == 0 { return; } - let divider = "─".repeat(width); + let theme = render_style.theme; + let surface_style = render_style.surface; + let top = position.y.saturating_add(top); + let divider = if use_ascii { "-" } else { "─" }.repeat(width); + let divider_style = theme.ui_style.muted.with_bg(surface_style.bg); buffer.set_text( position.x, top, &fit_display_width(÷r, width), - &theme.ui_style.muted, + ÷r_style, ); let rows = composer.config.rows.max(1); let content_width = width.saturating_sub(2).max(1); - let wrapped = wrap_text(&composer.draft, content_width); + let draft = composer.composer.contents(); + let wrapped = wrap_text(&draft, content_width); let cursor_row = wrapped .positions - .get(composer.cursor) + .get(composer.composer.cursor_grapheme_index()) .map_or(0, |position| position.0); let first = cursor_row.saturating_sub(rows.saturating_sub(1)); for row in 0..rows { @@ -1725,51 +2127,68 @@ fn render_text_panel_composer( .get(first + row) .map(String::as_str) .unwrap_or(""); - let text = if line.is_empty() && composer.draft.is_empty() && row == 0 { + let placeholder = line.is_empty() && draft.is_empty() && row == 0; + let text = if placeholder { composer.config.placeholder.as_str() } else { line }; - let style = if composer.enabled && composer.focused { - &theme.ui_style.dialog + let input_style = if composer.enabled && composer.focused { + theme.ui_style.dialog.with_bg(surface_style.bg) + } else { + theme.ui_style.muted.with_bg(surface_style.bg) + }; + let placeholder_style = theme.ui_style.muted.with_bg(surface_style.bg); + let text_style = if placeholder { + &placeholder_style } else { - &theme.ui_style.muted + &input_style }; - buffer.set_text(position.x, y, "›", &theme.ui_style.picker_prompt); + buffer.set_text(position.x, y, &" ".repeat(width), &input_style); buffer.set_text( - position.x + 2, + position.x.saturating_add(1), y, &fit_display_width(text, content_width), - style, + text_style, ); } - let hints = if composer.focused { - "Esc nav · Enter send · ^J newline · ^P/^N history" - } else { - "a edit · x clear · N new · q close · ^C stop" + let hints = text_panel_composer_hints(composer, width); + let status = composer + .composer + .validation_status() + .or(composer.status.as_deref()); + let status = match status { + Some(status) => format!("{status} | {hints}"), + None => hints, }; - let status = composer.validation.or(composer.status.as_deref()); - let status = status.map_or_else(|| hints.to_string(), |status| format!("{status} · {hints}")); + let footer_y = top + rows + 1; + let footer_style = &theme.ui_style.muted; + buffer.set_text(position.x, footer_y, &" ".repeat(width), footer_style); buffer.set_text( position.x, - top + rows + 1, + footer_y, &fit_display_width(&status, width), - &theme.ui_style.muted, + footer_style, ); } fn render_text_panel_status( buffer: &mut RenderBuffer, panel: &TextPanel, - status: &TextPanelStatus, position: Point, width: usize, y: usize, - theme: &Theme, + render_style: TextPanelRenderStyle<'_>, + use_ascii: bool, ) { if width == 0 { return; } + let theme = render_style.theme; + let surface_style = render_style.surface; + let Some(status) = panel.status.as_ref() else { + return; + }; let (text, style) = if status.busy { let elapsed_ms = panel .busy_since @@ -1777,16 +2196,24 @@ fn render_text_panel_status( ( format!( "{} {} · {}", - spinner_frame(elapsed_ms), + spinner_frame(elapsed_ms, use_ascii), status.label, format_elapsed(elapsed_ms / 1000) ), - &theme.ui_style.picker_prompt, + theme.ui_style.picker_prompt.with_bg(surface_style.bg), ) } else { - (status.label.clone(), &theme.ui_style.muted) + ( + status.label.clone(), + theme.ui_style.muted.with_bg(surface_style.bg), + ) }; - buffer.set_text(position.x, y, &fit_display_width(&text, width), style); + buffer.set_text( + position.x, + position.y.saturating_add(y), + &fit_display_width(&text, width), + &style, + ); } fn text_panel_header_actions(config: &PanelConfig, width: usize) -> Vec<(usize, &str, &str)> { @@ -1849,8 +2276,10 @@ fn render_text_spans( width: usize, line: &RenderedTextLine, selected_link: Option, - theme: &Theme, + render_style: TextPanelRenderStyle<'_>, ) { + let theme = render_style.theme; + let surface_style = render_style.surface; let mut used = 0; for span in &line.spans { if used >= width { @@ -1860,7 +2289,20 @@ fn render_text_spans( if text.is_empty() { continue; } - let mut style = text_panel_span_style(span.style, theme); + let mut style = span + .syntax_style + .clone() + .unwrap_or_else(|| text_panel_span_style(span.style, theme)); + style.fg = style.fg.or(surface_style.fg); + style.bg = if span.syntax_style.is_some() + || matches!( + span.style, + TextPanelSpanStyle::InlineCode | TextPanelSpanStyle::Code + ) { + style.bg.or(surface_style.bg) + } else { + surface_style.bg + }; if span .link .as_ref() @@ -1913,16 +2355,33 @@ fn render_panel_separator( height: usize, side: &PanelSide, style: &Style, + separator: &str, ) { - let separator_x = match side { - PanelSide::Left => position.x.checked_add(width), - PanelSide::Right => position.x.checked_sub(1), - }; - let Some(separator_x) = separator_x.filter(|x| *x < buffer.width) else { - return; - }; - for y in 0..height { - buffer.set_text(separator_x, y, "│", style); + match side { + PanelSide::Left | PanelSide::Right => { + let separator_x = if *side == PanelSide::Left { + position.x.checked_add(width) + } else { + position.x.checked_sub(1) + }; + let Some(separator_x) = separator_x.filter(|x| *x < buffer.width) else { + return; + }; + for y in 0..height { + buffer.set_text(separator_x, position.y.saturating_add(y), separator, style); + } + } + PanelSide::Top | PanelSide::Bottom => { + let separator_y = if *side == PanelSide::Top { + position.y.checked_add(height) + } else { + position.y.checked_sub(1) + }; + let Some(separator_y) = separator_y.filter(|y| *y < buffer.height) else { + return; + }; + buffer.set_text(position.x, separator_y, &separator.repeat(width), style); + } } } @@ -2110,6 +2569,54 @@ mod tests { .collect() } + fn focused_agent_composer() -> PanelManager { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 56, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up…".to_string(), + rows: 3, + }), + surface: None, + border: None, + header_actions: Vec::new(), + }, + ); + assert!(manager.focus_text_panel_composer("agent")); + manager + } + + fn composer_key(code: KeyCode, modifiers: KeyModifiers) -> Event { + Event::Key(crossterm::event::KeyEvent::new(code, modifiers)) + } + + fn focused_agent_conversation(side: PanelSide) -> PanelManager { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side, + width: if matches!(side, PanelSide::Top | PanelSide::Bottom) { + 10 + } else { + 24 + }, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up…".to_string(), + rows: 2, + }), + ..PanelConfig::default() + }, + ); + assert!(manager.focus_panel("agent")); + manager + } + #[test] fn left_panels_reserve_width_with_separator() { let mut manager = PanelManager::default(); @@ -2149,67 +2656,359 @@ mod tests { } #[test] - fn panel_separators_clear_stale_editor_cells_after_reflow() { + fn top_text_panel_uses_full_terminal_width_and_a_horizontal_separator() { let mut manager = PanelManager::default(); - manager.create_panel( - "left".to_string(), + manager.create_text_panel( + "agent".to_string(), PanelConfig { - side: PanelSide::Left, + side: PanelSide::Top, width: 4, - title: None, - composer: None, - surface: None, - border: None, - header_actions: Vec::new(), + title: Some("Top agent".to_string()), + ..PanelConfig::default() }, ); - manager.create_panel( - "right".to_string(), - PanelConfig { - side: PanelSide::Right, - width: 4, - title: None, - composer: None, - surface: None, - border: None, - header_actions: Vec::new(), - }, + manager.update_text_panel( + "agent", + vec![TextPanelBlock { + id: "answer".to_string(), + kind: TextPanelBlockKind::Agent, + format: TextPanelBlockFormat::Plain, + text: "LATEST horizontal response".to_string(), + }], + 10, + 32, ); - let style = Style::default(); - let theme = Theme { - style: style.clone(), - ..Theme::default() - }; - let mut buffer = RenderBuffer::new_with_contents(20, 5, style, vec!["x".repeat(20); 5]); + assert_eq!(manager.reserved_top_height(), 5); + assert_eq!( + manager.panel_at_position(31, 0, 32, 12), + Some(PanelPlacement { + id: "agent".to_string(), + x: 0, + y: 0, + width: 32, + height: 4, + }) + ); + assert!(manager.panel_at_position(0, 4, 32, 12).is_none()); + + let theme = Theme::default(); + let mut buffer = RenderBuffer::new(32, 12, &theme.style); manager.render(&mut buffer, &theme); - for y in 0..3 { - assert_eq!(buffer.cells[y * 20 + 4].text, " "); - assert_eq!(buffer.cells[y * 20 + 15].text, " "); - } + assert!(row_text(&buffer, 0).contains("Top agent")); + assert!((1..4).any(|y| row_text(&buffer, y).contains("LATEST"))); + assert_eq!(row_text(&buffer, 4), "─".repeat(32)); } #[test] - fn multiple_right_panels_keep_their_reserved_separator_columns() { + fn bottom_text_panel_renders_at_its_actual_vertical_origin() { let mut manager = PanelManager::default(); - for id in ["outer", "inner"] { - manager.create_panel( - id.to_string(), - PanelConfig { - side: PanelSide::Right, - width: 4, - title: None, - composer: None, - surface: None, - border: None, - header_actions: Vec::new(), - }, - ); - } - - assert_eq!(manager.reserved_right_width(), 10); - assert_eq!(manager.panel_at_position(16, 0, 20, 5).unwrap().id, "outer"); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side: PanelSide::Bottom, + width: 4, + title: Some("Bottom agent".to_string()), + ..PanelConfig::default() + }, + ); + manager.update_text_panel( + "agent", + vec![TextPanelBlock { + id: "answer".to_string(), + kind: TextPanelBlockKind::Agent, + format: TextPanelBlockFormat::Plain, + text: "LATEST bottom response".to_string(), + }], + 10, + 32, + ); + + assert_eq!(manager.reserved_bottom_height(), 5); + assert_eq!( + manager.panel_at_position(31, 6, 32, 12), + Some(PanelPlacement { + id: "agent".to_string(), + x: 0, + y: 6, + width: 32, + height: 4, + }) + ); + assert!(manager.panel_at_position(0, 5, 32, 12).is_none()); + + let theme = Theme::default(); + let mut buffer = RenderBuffer::new(32, 12, &theme.style); + manager.render(&mut buffer, &theme); + + assert_eq!(row_text(&buffer, 5), "─".repeat(32)); + assert!(row_text(&buffer, 6).contains("Bottom agent")); + assert!((7..10).any(|y| row_text(&buffer, y).contains("LATEST"))); + assert!(!(0..5).any(|y| row_text(&buffer, y).contains("LATEST"))); + } + + #[test] + fn four_sided_panels_reserve_disjoint_editor_and_separator_regions() { + let mut manager = PanelManager::default(); + for (id, side, width) in [ + ("left", PanelSide::Left, 5), + ("top", PanelSide::Top, 3), + ("bottom", PanelSide::Bottom, 4), + ("right", PanelSide::Right, 4), + ] { + manager.create_panel( + id.to_string(), + PanelConfig { + side, + width, + title: Some(id.to_string()), + ..PanelConfig::default() + }, + ); + } + + assert_eq!(manager.reserved_top_height(), 4); + assert_eq!(manager.reserved_bottom_height(), 5); + assert_eq!(manager.reserved_left_width(), 6); + assert_eq!(manager.reserved_right_width(), 5); + + let placements = manager.panel_placements(30, 18); + let placement = |id: &str| { + placements + .iter() + .find(|placement| placement.id == id) + .unwrap() + }; + assert_eq!(placement("top").x, 0); + assert_eq!(placement("top").y, 0); + assert_eq!(placement("top").width, 30); + assert_eq!(placement("top").height, 3); + assert_eq!(placement("bottom").x, 0); + assert_eq!(placement("bottom").y, 12); + assert_eq!(placement("bottom").width, 30); + assert_eq!(placement("bottom").height, 4); + assert_eq!(placement("left").x, 0); + assert_eq!(placement("left").y, 4); + assert_eq!(placement("left").width, 5); + assert_eq!(placement("left").height, 7); + assert_eq!(placement("right").x, 26); + assert_eq!(placement("right").y, 4); + assert_eq!(placement("right").width, 4); + assert_eq!(placement("right").height, 7); + + assert!(manager.panel_at_position(15, 3, 30, 18).is_none()); + assert!(manager.panel_at_position(15, 11, 30, 18).is_none()); + assert!(manager.panel_at_position(5, 7, 30, 18).is_none()); + assert!(manager.panel_at_position(25, 7, 30, 18).is_none()); + assert_eq!( + manager.panel_at_position(29, 12, 30, 18).unwrap().id, + "bottom" + ); + + let theme = Theme::default(); + let mut buffer = RenderBuffer::new(30, 18, &theme.style); + manager.render(&mut buffer, &theme); + assert!(row_text(&buffer, 0).contains("top")); + assert!(row_text(&buffer, 4).contains("left")); + assert!(row_text(&buffer, 4).contains("righ")); + assert!(row_text(&buffer, 12).contains("bottom")); + } + + #[test] + fn horizontal_composer_mouse_and_cursor_use_the_pane_vertical_origin() { + for side in [PanelSide::Top, PanelSide::Bottom] { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side, + width: 7, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask".to_string(), + rows: 2, + }), + ..PanelConfig::default() + }, + ); + + let placement = manager.panel_placements(48, 18).remove(0); + let composer_top = placement.y + placement.height - 4; + let event = manager + .focus_panel_at_position(1, composer_top + 1, 48, 18) + .unwrap(); + assert_eq!(event.action, "composer_focus"); + manager.handle_focused_text_input(&Event::Paste("hé 👋".to_string()), 48); + + let (cursor_x, cursor_y) = manager.focused_text_panel_cursor_position(48, 18).unwrap(); + assert!(cursor_x < placement.width); + assert!(cursor_y > composer_top); + assert!(cursor_y < placement.y + placement.height); + + let theme = Theme::default(); + let mut buffer = RenderBuffer::new(48, 18, &theme.style); + manager.render(&mut buffer, &theme); + assert!(row_text(&buffer, placement.y).contains("Agent")); + assert!(row_text(&buffer, composer_top + 1).contains("hé 👋")); + assert!(!row_text(&buffer, composer_top + 1).contains('›')); + } + } + + #[test] + fn moving_agent_between_docks_preserves_modal_draft_and_focus() { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 24, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask".to_string(), + rows: 2, + }), + ..PanelConfig::default() + }, + ); + assert!(manager.focus_text_panel_composer("agent")); + manager.handle_focused_text_input(&Event::Paste("keep my draft 👋".to_string()), 48); + + assert!(manager.update_panel_layout("agent", PanelSide::Top, 7)); + assert_eq!(manager.reserved_right_width(), 0); + assert_eq!(manager.reserved_top_height(), 8); + assert_eq!(manager.focused_panel_id(), Some("agent")); + assert!(manager.focused_text_input_active()); + assert_eq!( + manager.text_panels["agent"] + .composer + .as_ref() + .unwrap() + .composer + .contents(), + "keep my draft 👋" + ); + + assert!(manager.update_panel_layout("agent", PanelSide::Bottom, 7)); + assert_eq!(manager.reserved_top_height(), 0); + assert_eq!(manager.reserved_bottom_height(), 8); + assert!(manager.focused_text_input_active()); + let placement = manager.panel_placements(48, 18).remove(0); + let (_, cursor_y) = manager.focused_text_panel_cursor_position(48, 18).unwrap(); + assert!(cursor_y >= placement.y); + assert!(cursor_y < placement.y + placement.height); + assert_eq!( + manager.text_panels["agent"] + .composer + .as_ref() + .unwrap() + .composer + .contents(), + "keep my draft 👋" + ); + } + + #[test] + fn oversized_four_sided_panels_are_clipped_on_tiny_terminals() { + let mut manager = PanelManager::default(); + for (id, side) in [ + ("left", PanelSide::Left), + ("top", PanelSide::Top), + ("bottom", PanelSide::Bottom), + ("right", PanelSide::Right), + ] { + manager.create_panel( + id.to_string(), + PanelConfig { + side, + width: 99, + ..PanelConfig::default() + }, + ); + } + + let theme = Theme::default(); + for (width, height) in [(0, 0), (1, 1), (1, 2), (2, 3), (8, 5), (20, 8)] { + let placements = manager.panel_placements(width, height); + for (index, placement) in placements.iter().enumerate() { + assert!(placement.x + placement.width <= width); + assert!(placement.y + placement.height <= height.saturating_sub(2)); + for other in placements.iter().skip(index + 1) { + let separated = placement.x + placement.width <= other.x + || other.x + other.width <= placement.x + || placement.y + placement.height <= other.y + || other.y + other.height <= placement.y; + assert!(separated, "overlapping panes at {width}x{height}"); + } + } + + let mut buffer = RenderBuffer::new(width, height, &theme.style); + manager.render(&mut buffer, &theme); + } + } + + #[test] + fn panel_separators_clear_stale_editor_cells_after_reflow() { + let mut manager = PanelManager::default(); + manager.create_panel( + "left".to_string(), + PanelConfig { + side: PanelSide::Left, + width: 4, + title: None, + composer: None, + surface: None, + border: None, + header_actions: Vec::new(), + }, + ); + manager.create_panel( + "right".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 4, + title: None, + composer: None, + surface: None, + border: None, + header_actions: Vec::new(), + }, + ); + let style = Style::default(); + let theme = Theme { + style: style.clone(), + ..Theme::default() + }; + let mut buffer = RenderBuffer::new_with_contents(20, 5, style, vec!["x".repeat(20); 5]); + + manager.render(&mut buffer, &theme); + + for y in 0..3 { + assert_eq!(buffer.cells[y * 20 + 4].text, " "); + assert_eq!(buffer.cells[y * 20 + 15].text, " "); + } + } + + #[test] + fn multiple_right_panels_keep_their_reserved_separator_columns() { + let mut manager = PanelManager::default(); + for id in ["outer", "inner"] { + manager.create_panel( + id.to_string(), + PanelConfig { + side: PanelSide::Right, + width: 4, + title: None, + composer: None, + surface: None, + border: None, + header_actions: Vec::new(), + }, + ); + } + + assert_eq!(manager.reserved_right_width(), 10); + assert_eq!(manager.panel_at_position(16, 0, 20, 5).unwrap().id, "outer"); assert!(manager.panel_at_position(15, 0, 20, 5).is_none()); assert_eq!(manager.panel_at_position(11, 0, 20, 5).unwrap().id, "inner"); assert!(manager.panel_at_position(10, 0, 20, 5).is_none()); @@ -2230,6 +3029,330 @@ mod tests { assert_eq!(block.text, "# Heading"); } + #[test] + fn agent_conversation_confines_background_accents_to_its_footer_on_every_dock() { + for theme_path in ["themes/kanso.json", "themes/github-light.json"] { + let theme = parse_vscode_theme(theme_path).unwrap(); + let editor_background = theme.style.bg; + let footer_background = theme.ui_style.muted.bg; + + assert_ne!( + footer_background, editor_background, + "{theme_path} should exercise a genuinely contrasting footer", + ); + + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + for focus_composer in [false, true] { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent-conversation".to_string(), + PanelConfig { + side, + width: if matches!(side, PanelSide::Top | PanelSide::Bottom) { + 14 + } else { + 42 + }, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up…".to_string(), + rows: 2, + }), + surface: Some(ThemeStyleSpec { + foreground: vec![ + "sideBar.foreground".to_string(), + "editor.foreground".to_string(), + ], + background: vec!["editor.background".to_string()], + ..ThemeStyleSpec::default() + }), + border: Some(ThemeStyleSpec { + foreground: vec![ + "sideBar.border".to_string(), + "panel.border".to_string(), + ], + background: vec!["editor.background".to_string()], + ..ThemeStyleSpec::default() + }), + header_actions: vec![TextPanelHeaderAction { + id: "clear".to_string(), + label: "Clear".to_string(), + compact_label: Some("C".to_string()), + }], + }, + ); + manager.update_text_panel( + "agent-conversation", + vec![ + TextPanelBlock { + id: "user:1".to_string(), + kind: TextPanelBlockKind::User, + format: TextPanelBlockFormat::Plain, + text: "Explain the current file".to_string(), + }, + TextPanelBlock { + id: "agent:1".to_string(), + kind: TextPanelBlockKind::Agent, + format: TextPanelBlockFormat::Markdown, + text: "## Answer\n[Read the docs](https://example.com)\n> note" + .to_string(), + }, + ], + 30, + 100, + ); + assert!(manager.set_text_panel_status( + "agent-conversation", + Some(TextPanelStatus { + busy: true, + label: "Reading the current file".to_string(), + stream: false, + }), + )); + if focus_composer { + assert!(manager.focus_text_panel_composer("agent-conversation")); + } else { + assert!(manager.focus_panel("agent-conversation")); + } + + let mut buffer = RenderBuffer::new(100, 30, &theme.style); + manager.render(&mut buffer, &theme); + let placement = manager + .panel_placements(buffer.width, buffer.height) + .into_iter() + .find(|placement| placement.id == "agent-conversation") + .unwrap(); + let footer_y = placement.y + placement.height - 1; + + for y in placement.y..placement.y + placement.height { + for x in placement.x..placement.x + placement.width { + let expected_background = if y == footer_y { + footer_background + } else { + editor_background + }; + assert_eq!( + buffer.cells[y * buffer.width + x].style.bg, + expected_background, + "{theme_path}, {side:?}, composer_focused={focus_composer}, \ + x={x}, y={y}", + ); + } + } + + let title = &buffer.cells[placement.y * buffer.width + placement.x]; + assert_eq!( + title.style.fg, + theme.colors.get("sideBar.foreground").copied(), + ); + assert!(title.style.bold); + + let footer = row_text(&buffer, footer_y); + assert!( + footer.contains(if focus_composer { "INSERT" } else { "READ" }), + "the localized footer should retain Vim-mode guidance: {footer}", + ); + } + } + } + } + + #[test] + fn custom_text_panel_keeps_its_requested_surface_background() { + let theme = parse_vscode_theme("themes/github-light.json").unwrap(); + let sidebar_background = theme.colors["sideBar.background"]; + assert_ne!(Some(sidebar_background), theme.style.bg); + + let mut manager = PanelManager::default(); + manager.create_text_panel( + "custom-conversation".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 36, + title: Some("Custom conversation".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Custom prompt".to_string(), + rows: 2, + }), + surface: Some(ThemeStyleSpec { + foreground: vec!["sideBar.foreground".to_string()], + background: vec!["sideBar.background".to_string()], + ..ThemeStyleSpec::default() + }), + header_actions: vec![TextPanelHeaderAction { + id: "clear".to_string(), + label: "Clear".to_string(), + compact_label: Some("C".to_string()), + }], + ..PanelConfig::default() + }, + ); + manager.update_text_panel( + "custom-conversation", + vec![TextPanelBlock { + id: "answer".to_string(), + kind: TextPanelBlockKind::Agent, + format: TextPanelBlockFormat::Markdown, + text: "# Custom heading\n> Custom note".to_string(), + }], + 20, + 80, + ); + assert!(manager.focus_text_panel_composer("custom-conversation")); + + let mut buffer = RenderBuffer::new(80, 20, &theme.style); + manager.render(&mut buffer, &theme); + let placement = manager + .panel_placements(buffer.width, buffer.height) + .into_iter() + .find(|placement| placement.id == "custom-conversation") + .unwrap(); + let footer_y = placement.y + placement.height - 1; + + for y in placement.y..placement.y + placement.height { + for x in placement.x..placement.x + placement.width { + let expected_background = if y == footer_y { + theme.ui_style.muted.bg + } else { + Some(sidebar_background) + }; + assert_eq!( + buffer.cells[y * buffer.width + x].style.bg, + expected_background, + "custom text surfaces should remain intact at x={x}, y={y}", + ); + } + } + } + + #[test] + fn text_panel_renders_fenced_code_with_the_editors_real_syntax_styles() { + let theme = parse_vscode_theme("themes/kanso.json").unwrap(); + let mut highlighter = Highlighter::new(&theme).unwrap(); + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side: PanelSide::Left, + width: 48, + title: Some("Agent".to_string()), + ..PanelConfig::default() + }, + ); + manager.update_text_panel( + "agent", + vec![TextPanelBlock { + id: "agent:1".to_string(), + kind: TextPanelBlockKind::Agent, + format: TextPanelBlockFormat::Markdown, + text: "```rust\nfn main() {}\n```".to_string(), + }], + 24, + 80, + ); + + let expected_keyword_style = manager.text_panels["agent"] + .rendered_lines_with_highlighter(48, Some(&mut highlighter)) + .into_iter() + .flat_map(|line| line.spans) + .find(|span| span.text == "fn" && span.syntax_style.is_some()) + .and_then(|span| span.syntax_style) + .expect("fenced Rust keywords should receive tree-sitter syntax styles"); + + let mut buffer = RenderBuffer::new(80, 24, &theme.style); + manager.render_with_highlighter(&mut buffer, &theme, &mut highlighter, false); + let (row, column) = (0..buffer.height) + .find_map(|row| { + let text = row_text(&buffer, row); + text.find("fn main") + .map(|byte| (row, display_width(&text[..byte]))) + }) + .expect("fenced Rust source should be visible in the conversation"); + + assert_eq!( + buffer.cells[row * buffer.width + column].style, + expected_keyword_style.with_bg(expected_keyword_style.bg.or(theme.style.bg)) + ); + } + + #[test] + fn ascii_panel_rendering_uses_portable_borders_dividers_and_spinner() { + let theme = Theme::default(); + let mut highlighter = Highlighter::new(&theme).unwrap(); + let mut manager = PanelManager::default(); + manager.create_text_panel( + "top".to_string(), + PanelConfig { + side: PanelSide::Top, + width: 4, + title: Some("Top".to_string()), + ..PanelConfig::default() + }, + ); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 28, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up".to_string(), + rows: 2, + }), + ..PanelConfig::default() + }, + ); + assert!(manager.set_text_panel_status( + "agent", + Some(TextPanelStatus { + busy: true, + label: "Working".to_string(), + stream: false, + }), + )); + + let mut buffer = RenderBuffer::new(80, 24, &theme.style); + manager.render_with_highlighter(&mut buffer, &theme, &mut highlighter, true); + let placements = manager.panel_placements(buffer.width, buffer.height); + let top = placements + .iter() + .find(|placement| placement.id == "top") + .unwrap(); + let agent = placements + .iter() + .find(|placement| placement.id == "agent") + .unwrap(); + + assert_eq!( + buffer.cells[(top.y + top.height) * buffer.width + top.x].text, + "-" + ); + assert_eq!(buffer.cells[agent.y * buffer.width + agent.x - 1].text, "|"); + let divider_y = agent.y + agent.height - manager.text_panels["agent"].composer_height(); + assert!(row_text(&buffer, divider_y).contains(&"-".repeat(agent.width))); + let status_row = (agent.y..agent.y + agent.height) + .map(|row| row_text(&buffer, row)) + .find(|row| row.contains("Working")) + .expect("busy agent status should remain visible in ASCII mode"); + assert!( + TEXT_PANEL_ASCII_SPINNER_FRAMES + .iter() + .any(|frame| status_row.contains(frame)), + "busy status should use a portable ASCII animation: {status_row}" + ); + assert!( + !TEXT_PANEL_SPINNER_FRAMES + .iter() + .any(|frame| status_row.contains(frame)), + "busy status should not contain a Unicode spinner in ASCII mode" + ); + } + #[test] fn text_panel_composer_edits_unicode_submits_and_recalls_history() { use crossterm::event::KeyEvent; @@ -2260,9 +3383,24 @@ mod tests { &Event::Key(KeyEvent::new(KeyCode::Char('世'), KeyModifiers::NONE)), 80, ); + assert!(manager + .handle_focused_text_input( + &Event::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)), + 80, + ) + .is_none()); + assert_eq!( + manager.text_panels["agent"] + .composer + .as_ref() + .unwrap() + .composer + .contents(), + "one 👨‍👩‍👧\ntwo\n世", + ); let submitted = manager .handle_focused_text_input( - &Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + &Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)), 80, ) .unwrap(); @@ -2275,14 +3413,528 @@ mod tests { 80, ); let recalled = manager.text_panels["agent"].composer.as_ref().unwrap(); - assert_eq!(recalled.draft, "one 👨‍👩‍👧\ntwo\n世"); + assert_eq!(recalled.composer.contents(), "one 👨‍👩‍👧\ntwo\n世"); manager.handle_focused_text_input( &Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)), 80, ); let restored = manager.text_panels["agent"].composer.as_ref().unwrap(); - assert_eq!(restored.draft, "draft"); + assert_eq!(restored.composer.contents(), "draft"); + assert!(manager.focused_text_panel_cursor_position(80, 20).is_some()); + } + + #[test] + fn modified_enter_terminal_variants_submit_insert_drafts_on_every_panel_side() { + use crossterm::event::{KeyEvent, KeyEventKind}; + + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + for code in [KeyCode::Enter, KeyCode::Char('\n'), KeyCode::Char('\r')] { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { + let mut manager = focused_agent_conversation(side); + assert!(manager.focus_text_panel_composer("agent")); + manager.handle_focused_text_input( + &Event::Paste("first\r\n漢👨‍👩‍👧\r\nsecond".to_string()), + 80, + ); + + let submitted = manager.handle_focused_text_input( + &Event::Key(KeyEvent::new_with_kind(code, modifiers, kind)), + 80, + ); + + let event = submitted.unwrap_or_else(|| { + panic!( + "{code:?} with {modifiers:?} and {kind:?} must submit from the {side:?} Insert composer" + ) + }); + assert_eq!(event.panel_id, "agent"); + assert_eq!(event.action, "submit"); + assert_eq!(event.text.as_deref(), Some("first\n漢👨‍👩‍👧\nsecond")); + assert_eq!(manager.focused_panel_id(), Some("agent")); + assert!(manager.focused_text_input_active()); + assert_eq!( + manager.text_panels["agent"] + .composer + .as_ref() + .unwrap() + .composer + .mode(), + ModalComposerMode::Insert + ); + assert_eq!( + manager.text_panels["agent"] + .composer + .as_ref() + .unwrap() + .composer + .contents(), + "" + ); + } + } + } + } + } + + #[test] + fn focused_composer_reports_real_vim_cursor_mode_for_every_dock_side() { + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side, + width: 16, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up…".to_string(), + rows: 3, + }), + ..PanelConfig::default() + }, + ); + + assert_eq!(manager.focused_text_panel_cursor_mode(), None); + assert!(manager.focus_text_panel_composer("agent")); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Insert), + "a newly focused {side:?} composer must own the Insert cursor", + ); + + manager.handle_focused_text_input( + &Event::Paste("e\u{301} 👨‍👩‍👧 漢 123456789\nsecond line".to_string()), + 48, + ); + + let placement = manager + .panel_placements(48, 24) + .into_iter() + .find(|placement| placement.id == "agent") + .unwrap(); + let (cursor_x, cursor_y) = manager.focused_text_panel_cursor_position(48, 24).unwrap(); + + assert!( + (placement.x..placement.x + placement.width).contains(&cursor_x), + "{side:?} cursor x={cursor_x} must remain inside its dock", + ); + assert!( + (placement.y..placement.y + placement.height).contains(&cursor_y), + "{side:?} cursor y={cursor_y} must remain inside its dock", + ); + + manager.handle_focused_text_input(&composer_key(KeyCode::Esc, KeyModifiers::NONE), 48); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Normal), + "Escape must switch the {side:?} composer to the Normal cursor", + ); + + manager.handle_focused_text_input( + &composer_key(KeyCode::Char('v'), KeyModifiers::NONE), + 48, + ); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Visual), + "v must switch the {side:?} composer to the Visual cursor", + ); + + manager.handle_focused_text_input(&composer_key(KeyCode::Esc, KeyModifiers::NONE), 48); + manager.handle_focused_text_input( + &composer_key(KeyCode::Char('i'), KeyModifiers::NONE), + 48, + ); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Insert), + "i must restore the {side:?} composer's Insert cursor", + ); + } + } + + #[test] + fn empty_conversation_has_visible_normal_cursor_on_every_dock_side() { + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + let mut manager = focused_agent_conversation(side); + let placement = manager + .panel_placements(64, 24) + .into_iter() + .find(|placement| placement.id == "agent") + .unwrap(); + + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Normal), + "an empty {side:?} conversation must own a Normal cursor", + ); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x, placement.y + 1)), + "an empty {side:?} conversation must use its first content row", + ); + assert!(!manager.focused_text_input_active()); + + assert!(manager.set_panel_visible("agent", false)); + assert_eq!(manager.focused_text_panel_cursor_mode(), None); + assert_eq!(manager.focused_text_panel_cursor_position(64, 24), None); + } + } + + #[test] + fn conversation_mouse_cursor_snaps_to_unicode_markdown_graphemes_on_every_side() { + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + let mut manager = focused_agent_conversation(side); + manager.update_text_panel( + "agent", + vec![TextPanelBlock { + id: "answer".to_string(), + kind: TextPanelBlockKind::Text, + format: TextPanelBlockFormat::Markdown, + text: "e\u{301} 👨‍👩‍👧 漢 **x**\n\nsecond".to_string(), + }], + 22, + 64, + ); + let placement = manager + .panel_placements(64, 24) + .into_iter() + .find(|placement| placement.id == "agent") + .unwrap(); + let content_y = placement.y + 1; + + let family = manager + .focus_panel_at_position(placement.x + 3, content_y, 64, 24) + .unwrap(); + assert_eq!(family.action, "select"); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x + 2, content_y)), + "a {side:?} mouse click inside a family emoji must snap to its first cell", + ); + + manager.focus_panel_at_position(placement.x + 6, content_y, 64, 24); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x + 5, content_y)), + "a {side:?} mouse click inside CJK must snap to its first cell", + ); + + manager.focus_panel_at_position(placement.x + placement.width - 1, content_y, 64, 24); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x + 8, content_y)), + "a {side:?} click beyond Markdown text must clamp to its last grapheme", + ); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Normal), + ); + } + } + + #[test] + fn transcript_vim_navigation_preserves_unicode_cursor_and_stream_following() { + let mut manager = focused_agent_conversation(PanelSide::Right); + let transcript = (0..24) + .map(|index| format!("line-{index:02} 👨‍👩‍👧")) + .collect::>() + .join("\n"); + manager.update_text_panel( + "agent", + vec![TextPanelBlock { + id: "answer".to_string(), + kind: TextPanelBlockKind::Text, + format: TextPanelBlockFormat::Plain, + text: transcript, + }], + 22, + 64, + ); + let placement = manager + .panel_placements(64, 24) + .into_iter() + .find(|placement| placement.id == "agent") + .unwrap(); + + assert!(manager.text_panels["agent"].follow_tail); + manager.handle_focused_key("top", 22, 64, 0); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x, placement.y + 1)), + ); + assert!(!manager.text_panels["agent"].follow_tail); + + manager.handle_focused_key("down", 22, 64, 0); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x, placement.y + 2)), + ); + manager.handle_focused_key("expand", 22, 64, 0); + manager.handle_focused_key("right", 22, 64, 0); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x + 2, placement.y + 2)), + ); + manager.handle_focused_key("collapse", 22, 64, 0); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x + 1, placement.y + 2)), + ); + + manager.handle_focused_key("page_down", 22, 64, 0); + assert!(manager.text_panels["agent"].scroll > 0); + assert!(!manager.text_panels["agent"].follow_tail); + + manager.handle_focused_key("top", 22, 64, 0); + manager.append_text_panel("agent", "answer", "\nmanual append", 22, 64); + assert_eq!(manager.text_panels["agent"].scroll, 0); + assert_eq!( + manager.focused_text_panel_cursor_position(64, 24), + Some((placement.x, placement.y + 1)), + ); + + manager.handle_focused_key("bottom", 22, 64, 0); + let previous_scroll = manager.text_panels["agent"].scroll; + manager.append_text_panel("agent", "answer", "\nlatest 👋", 22, 64); + let panel = &manager.text_panels["agent"]; + assert!(panel.follow_tail); + assert!(panel.scroll > previous_scroll); + assert_eq!( + panel.transcript_cursor.row, + panel.rendered_lines(placement.width).len() - 1, + ); + let (cursor_x, cursor_y) = manager.focused_text_panel_cursor_position(64, 24).unwrap(); + assert!((placement.x..placement.x + placement.width).contains(&cursor_x)); + assert!((placement.y..placement.y + placement.height).contains(&cursor_y)); + } + + #[test] + fn composer_hints_prioritize_control_enter_in_every_mode_and_narrow_width() { + let mut manager = focused_agent_composer(); + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + let wide = text_panel_composer_hints(composer, 100); + assert!(wide.contains("Ctrl+Enter send")); + assert!(wide.contains("Alt+Enter send")); + assert!(wide.contains("Enter newline")); + + let narrow = text_panel_composer_hints(composer, 24); + assert!(narrow.contains("Ctrl+Enter send")); + assert!(display_width(&narrow) <= 24); + assert!(!narrow.contains("^S")); + + manager.handle_focused_text_input(&composer_key(KeyCode::Esc, KeyModifiers::NONE), 80); + let normal = + text_panel_composer_hints(manager.text_panels["agent"].composer.as_ref().unwrap(), 36); + assert!(normal.starts_with("NORMAL")); + assert!(normal.contains("Ctrl+Enter send")); + + manager + .handle_focused_text_input(&composer_key(KeyCode::Char('v'), KeyModifiers::NONE), 80); + let visual = + text_panel_composer_hints(manager.text_panels["agent"].composer.as_ref().unwrap(), 30); + assert!(visual.starts_with("VISUAL")); + assert!(visual.contains("Ctrl+Enter send")); + assert!(!visual.contains("^S")); + } + + #[test] + fn focused_text_panel_retains_normal_cursor_when_composer_blurs_or_disables() { + let mut manager = focused_agent_composer(); + + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Insert), + ); + + manager.focus_editor(); + assert_eq!(manager.focused_text_panel_cursor_mode(), None); + assert_eq!(manager.focused_text_panel_cursor_position(80, 20), None); + + assert!(manager.focus_text_panel_composer("agent")); + assert!(manager.set_text_panel_composer_state("agent", false, None)); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Normal), + ); + assert!(manager.focused_text_panel_cursor_position(80, 20).is_some()); + + assert!(manager.set_text_panel_composer_state("agent", true, None)); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Normal), + ); assert!(manager.focused_text_panel_cursor_position(80, 20).is_some()); + + assert!(manager.focus_text_panel_composer("agent")); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Insert), + ); + + let blurred = manager + .handle_focused_text_input(&composer_key(KeyCode::Char('c'), KeyModifiers::CONTROL), 80) + .unwrap(); + + assert_eq!(blurred.action, "composer_blur"); + assert_eq!( + manager.focused_text_panel_cursor_mode(), + Some(crate::editor::Mode::Normal), + ); + assert!(manager.focused_text_panel_cursor_position(80, 20).is_some()); + } + + #[test] + fn text_panel_insert_enter_creates_newline_and_escape_only_enters_normal() { + let mut manager = focused_agent_composer(); + + let entered = manager + .handle_focused_text_input(&composer_key(KeyCode::Enter, KeyModifiers::NONE), 80) + .unwrap(); + assert_eq!(entered.action, "composer_input"); + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + assert_eq!(composer.composer.contents(), "\n"); + assert_eq!(composer.composer.mode(), ModalComposerMode::Insert); + + let escaped = manager + .handle_focused_text_input(&composer_key(KeyCode::Esc, KeyModifiers::NONE), 80) + .unwrap(); + assert_eq!(escaped.action, "composer_input"); + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + assert!(composer.focused); + assert_eq!(composer.composer.mode(), ModalComposerMode::Normal); + assert_eq!(composer.composer.contents(), "\n"); + } + + #[test] + fn text_panel_normal_enter_submits_and_restores_insert_mode() { + let mut manager = focused_agent_composer(); + manager.handle_focused_text_input(&Event::Paste("first\nprompt".to_string()), 80); + manager.handle_focused_text_input(&composer_key(KeyCode::Esc, KeyModifiers::NONE), 80); + + let submitted = manager + .handle_focused_text_input(&composer_key(KeyCode::Enter, KeyModifiers::NONE), 80) + .unwrap(); + + assert_eq!(submitted.action, "submit"); + assert_eq!(submitted.text.as_deref(), Some("first\nprompt")); + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + assert!(composer.focused); + assert_eq!(composer.composer.mode(), ModalComposerMode::Insert); + assert!(composer.composer.contents().is_empty()); + } + + #[test] + fn text_panel_normal_mode_uses_real_buffer_operators_undo_and_redo() { + let mut manager = focused_agent_composer(); + manager.handle_focused_text_input(&Event::Paste("one two three".to_string()), 80); + for code in [ + KeyCode::Esc, + KeyCode::Char('0'), + KeyCode::Char('d'), + KeyCode::Char('w'), + ] { + manager.handle_focused_text_input(&composer_key(code, KeyModifiers::NONE), 80); + } + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + assert_eq!(composer.composer.contents(), "two three"); + + manager + .handle_focused_text_input(&composer_key(KeyCode::Char('u'), KeyModifiers::NONE), 80); + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + assert_eq!(composer.composer.contents(), "one two three"); + + manager.handle_focused_text_input( + &composer_key(KeyCode::Char('r'), KeyModifiers::CONTROL), + 80, + ); + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + assert_eq!(composer.composer.contents(), "two three"); + } + + #[test] + fn text_panel_control_c_blurs_without_discarding_the_real_buffer() { + let mut manager = focused_agent_composer(); + manager.handle_focused_text_input(&Event::Paste("keep 👨‍👩‍👧".to_string()), 80); + + let blurred = manager + .handle_focused_text_input(&composer_key(KeyCode::Char('c'), KeyModifiers::CONTROL), 80) + .unwrap(); + + assert_eq!(blurred.action, "composer_blur"); + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + assert!(!composer.focused); + assert_eq!(composer.composer.contents(), "keep 👨‍👩‍👧"); + } + + #[test] + fn text_panel_rejects_oversized_paste_without_losing_existing_draft() { + let mut manager = focused_agent_composer(); + manager.handle_focused_text_input(&Event::Paste("draft".to_string()), 80); + + let rejected = manager + .handle_focused_text_input(&Event::Paste("x".repeat(128 * 1024)), 80) + .unwrap(); + + assert_eq!(rejected.action, "composer_input"); + let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); + assert_eq!(composer.composer.contents(), "draft"); + assert_eq!( + composer.composer.validation_status(), + Some("Prompt exceeds 128 KiB") + ); + } + + #[test] + fn text_panel_composer_renders_plain_unprefixed_input_and_vim_mode() { + let manager = focused_agent_composer(); + let theme = Theme::default(); + let mut buffer = RenderBuffer::new(80, 20, &theme.style); + + manager.render(&mut buffer, &theme); + + let placement = manager + .panel_placements(80, 20) + .into_iter() + .find(|placement| placement.id == "agent") + .unwrap(); + let panel = &manager.text_panels["agent"]; + let top = placement + .y + .saturating_add(placement.height.saturating_sub(panel.composer_height())); + let input = row_text(&buffer, top + 1); + let footer = row_text(&buffer, top + panel.composer_height() - 1); + + assert!(input.contains("Ask a follow-up…")); + assert!(!input.contains('›')); + assert!(!input.contains("> ")); + assert!(footer.contains("INSERT")); + assert!(footer.contains("Ctrl+Enter send")); + assert!(!footer.contains("^S send")); } #[test] @@ -2450,6 +4102,75 @@ mod tests { ); } + #[test] + fn selected_text_panel_links_keep_their_confined_selection_accent() { + let theme = parse_vscode_theme("themes/github-light.json").unwrap(); + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent-conversation".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 40, + title: Some("Agent".to_string()), + surface: Some(ThemeStyleSpec { + foreground: vec!["sideBar.foreground".to_string()], + background: vec!["editor.background".to_string()], + ..ThemeStyleSpec::default() + }), + ..PanelConfig::default() + }, + ); + manager.update_text_panel( + "agent-conversation", + vec![TextPanelBlock { + id: "answer".to_string(), + kind: TextPanelBlockKind::Agent, + format: TextPanelBlockFormat::Markdown, + text: "Read the [docs](https://example.com)".to_string(), + }], + 20, + 80, + ); + assert!(manager.focus_panel("agent-conversation")); + assert!(manager.select_focused_text_link(true, 20, 80)); + + let mut buffer = RenderBuffer::new(80, 20, &theme.style); + manager.render(&mut buffer, &theme); + let (row, column) = (0..buffer.height) + .find_map(|row| { + let text = row_text(&buffer, row); + text.find("docs") + .map(|byte| (row, display_width(&text[..byte]))) + }) + .expect("the focused Markdown link should remain visible"); + let selected = &buffer.cells[row * buffer.width + column]; + let surface_style = panel_style( + &theme, + manager.text_panels["agent-conversation"] + .config + .surface + .as_ref(), + ); + let mut link_style = text_panel_span_style(TextPanelSpanStyle::Link, &theme); + link_style.fg = link_style.fg.or(surface_style.fg); + link_style.bg = surface_style.bg; + let selected_style = theme.selected_style( + &link_style, + &theme.list_selection_style(), + SelectionForegroundPriority::Content, + ); + + assert_ne!(selected.style.bg, theme.style.bg); + assert_eq!(selected.style, selected_style); + assert_eq!( + buffer.cells[row * buffer.width + column.saturating_sub(1)] + .style + .bg, + theme.style.bg, + "selection must be confined to the linked text", + ); + } + #[test] fn text_panel_composer_click_places_cursor_in_wrapped_text() { let mut manager = PanelManager::default(); @@ -2471,12 +4192,12 @@ mod tests { assert!(manager.focus_text_panel_composer("agent")); manager.handle_focused_text_input(&Event::Paste("first line\nsecond line".to_string()), 80); - let event = manager.focus_panel_at_position(53, 15, 80, 20).unwrap(); + let event = manager.focus_panel_at_position(52, 15, 80, 20).unwrap(); assert_eq!(event.action, "composer_focus"); manager.handle_focused_text_input(&Event::Paste("X".to_string()), 80); let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); - assert_eq!(composer.draft, "first line\nsecXond line"); + assert_eq!(composer.composer.contents(), "first line\nsecXond line"); } #[test] @@ -2509,7 +4230,7 @@ mod tests { assert_eq!(manager.reserved_right_width(), 25); assert!(manager.focus_text_panel_composer("agent")); let composer = manager.text_panels["agent"].composer.as_ref().unwrap(); - assert_eq!(composer.draft, "keep this draft"); + assert_eq!(composer.composer.contents(), "keep this draft"); } #[test] @@ -3010,7 +4731,7 @@ mod tests { ..Theme::default() }; let mut buffer = RenderBuffer::new(10, 5, &style); - render_panel(&mut buffer, &panel, Point::new(0, 0), 10, &theme); + render_panel(&mut buffer, &panel, Point::new(0, 0), 10, 3, &theme); assert_eq!(row_text(&buffer, 0), "src M "); } @@ -3035,7 +4756,7 @@ mod tests { panel.update_rows(vec![row("other"), directory_row]); let mut buffer = RenderBuffer::new(10, 5, &theme.style); - render_panel(&mut buffer, &panel, Point::new(0, 0), 10, &theme); + render_panel(&mut buffer, &panel, Point::new(0, 0), 10, 3, &theme); assert_eq!(buffer.cells[10].style.fg, Some(directory_color)); } @@ -3060,7 +4781,7 @@ mod tests { ..Theme::default() }; let mut buffer = RenderBuffer::new(10, 5, &style); - render_panel(&mut buffer, &panel, Point::new(0, 0), 10, &theme); + render_panel(&mut buffer, &panel, Point::new(0, 0), 10, 3, &theme); let selected_bg = Some(Color::Rgb { r: 255, @@ -3150,7 +4871,7 @@ mod tests { ..Theme::default() }; let mut buffer = RenderBuffer::new(10, 5, &style); - render_panel(&mut buffer, &panel, Point::new(0, 0), 10, &theme); + render_panel(&mut buffer, &panel, Point::new(0, 0), 10, 3, &theme); assert_eq!(buffer.cells[8].text, ""); assert_eq!(buffer.cells[9].text, " "); @@ -3172,7 +4893,7 @@ mod tests { panel.update_rows(vec![row]); let mut buffer = RenderBuffer::new(10, 5, &theme.style); - render_panel(&mut buffer, &panel, Point::new(0, 0), 10, &theme); + render_panel(&mut buffer, &panel, Point::new(0, 0), 10, 3, &theme); let selected = &buffer.cells[0].style; let selected_bg = selected.bg.unwrap(); @@ -3208,7 +4929,7 @@ mod tests { ..Theme::default() }; let mut buffer = RenderBuffer::new(6, 5, &style); - render_panel(&mut buffer, &panel, Point::new(0, 0), 6, &theme); + render_panel(&mut buffer, &panel, Point::new(0, 0), 6, 3, &theme); assert_eq!(row_text(&buffer, 0), "abc M "); } diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 4cf71b84..0c14b6ae 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -32,7 +32,7 @@ pub struct PluginRegistry { } /// Host API version used for plugin compatibility checks. -pub const RED_HOST_API_VERSION: &str = "0.4.0"; +pub const RED_HOST_API_VERSION: &str = "0.4.1"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct PluginModification { diff --git a/src/plugin/runtime.rs b/src/plugin/runtime.rs index 9fbe2eda..fad7278d 100644 --- a/src/plugin/runtime.rs +++ b/src/plugin/runtime.rs @@ -31,7 +31,7 @@ use uuid::Uuid; use crate::{ assets::RuntimeAssetKind, - config::{Config, PluginPermissions}, + config::{AgentPosition, Config, PluginPermissions}, editor::{ Action, ComposerCallback, ComposerCallbackKind, PickerCallback, PickerCallbackKind, PluginRequest, ACTION_DISPATCHER, @@ -632,6 +632,19 @@ impl RedHost { .map_or_else(|| PathBuf::from("."), PathBuf::from); self.send_request(PluginRequest::AgentNewSession { cwd }); } + "AgentResumeSession" => { + let session_id = args + .first() + .and_then(Value::as_str) + .filter(|session_id| !session_id.is_empty()) + .ok_or_else(|| anyhow::anyhow!("AgentResumeSession requires a session id"))? + .to_string(); + let cwd = args + .get(1) + .and_then(Value::as_str) + .map_or_else(|| PathBuf::from("."), PathBuf::from); + self.send_request(PluginRequest::AgentResumeSession { session_id, cwd }); + } "AgentPrompt" => { let session_id = args .first() @@ -669,6 +682,86 @@ impl RedHost { context, }); } + "AgentSteer" => { + let session_id = args + .first() + .and_then(Value::as_str) + .filter(|session_id| !session_id.is_empty()) + .ok_or_else(|| anyhow::anyhow!("AgentSteer requires a session id"))? + .to_string(); + let text = args.get(1).map(value_to_string).unwrap_or_default(); + anyhow::ensure!(!text.trim().is_empty(), "AgentSteer requires nonempty text"); + self.send_request(PluginRequest::AgentSteer { session_id, text }); + } + "AgentListModels" => { + let session_id = args + .first() + .and_then(Value::as_str) + .filter(|session_id| !session_id.is_empty()) + .ok_or_else(|| anyhow::anyhow!("AgentListModels requires a session id"))? + .to_string(); + self.send_request(PluginRequest::AgentListModels { session_id }); + } + "AgentListSessions" => { + let session_id = args + .first() + .and_then(Value::as_str) + .filter(|session_id| !session_id.is_empty()) + .ok_or_else(|| anyhow::anyhow!("AgentListSessions requires a session id"))? + .to_string(); + let cwd = args + .get(1) + .and_then(Value::as_str) + .map_or_else(|| PathBuf::from("."), PathBuf::from); + self.send_request(PluginRequest::AgentListSessions { session_id, cwd }); + } + "AgentSetModel" => { + let session_id = args + .first() + .and_then(Value::as_str) + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + .ok_or_else(|| anyhow::anyhow!("AgentSetModel requires a session id"))? + .to_string(); + let model = args + .get(1) + .and_then(Value::as_str) + .map(str::trim) + .filter(|model| !model.is_empty()) + .ok_or_else(|| anyhow::anyhow!("AgentSetModel requires a model"))? + .to_string(); + anyhow::ensure!( + model.len() <= 256 && !model.chars().any(char::is_control), + "AgentSetModel received an invalid model" + ); + let reasoning_effort = args + .get(2) + .and_then(Value::as_str) + .map(str::trim) + .filter(|effort| !effort.is_empty()) + .map(str::to_string); + anyhow::ensure!( + reasoning_effort.as_deref().is_none_or(|effort| { + effort.len() <= 64 && !effort.chars().any(char::is_control) + }), + "AgentSetModel received an invalid reasoning effort" + ); + self.send_request(PluginRequest::AgentSetModel { + session_id, + model, + reasoning_effort, + }); + } + "AgentSetReasoningEffort" => { + let effort = args + .first() + .and_then(Value::as_str) + .map(str::trim) + .filter(|effort| !effort.is_empty()) + .ok_or_else(|| anyhow::anyhow!("AgentSetReasoningEffort requires an effort"))? + .to_string(); + self.send_request(PluginRequest::AgentSetReasoningEffort { effort }); + } "AgentCancel" => { let session_id = args .first() @@ -1315,6 +1408,17 @@ impl RedHost { let visible = args.get(1).and_then(Value::as_bool).unwrap_or(true); self.send_request(PluginRequest::SetPanelVisible { id, visible }); } + "SetAgentPosition" => { + let position = args + .first() + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("SetAgentPosition requires a position"))?; + let position = serde_json::from_value::(serde_json::Value::String( + position.to_string(), + )) + .map_err(|error| anyhow::anyhow!("invalid agent position: {error}"))?; + self.send_request(PluginRequest::SetAgentPosition { position }); + } "ClosePanel" => { let id = args .first() @@ -3139,10 +3243,13 @@ mod tests { #[cfg(not(windows))] use std::{fs, process::Command}; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + use super::*; use crate::{ color::Color, editor::{PluginRequest, PLUGIN_DISPATCHER_TEST_LOCK}, + plugin::{PanelManager, PanelSide, TextPanelComposerConfig}, ui::PickerPresentation, }; @@ -4063,6 +4170,10 @@ mod tests { && config.side == crate::plugin::PanelSide::Right && config.width == 62 && config.title.as_deref() == Some("Agent") + && config.surface.as_ref().is_some_and(|surface| + surface.background == ["editor.background"] + && surface.foreground == ["sideBar.foreground", "editor.foreground"] + ) && config.header_actions.iter().map(|action| action.id.as_str()).eq(["clear", "new", "close"]) )); assert!(matches!( @@ -4186,6 +4297,13 @@ mod tests { && status.label == "Writing…" && status.stream )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::AppendTextPanel { id, block_id, delta } + if id == "agent-conversation" + && block_id == "agent:2" + && delta == "streamed output" + )); runtime .notify( "agent:update", @@ -4221,7 +4339,7 @@ mod tests { PluginRequest::AppendTextPanel { id, block_id, delta } if id == "agent-conversation" && block_id == "agent:2" - && delta == "streamed output 👋\nnext line" + && delta == " 👋\nnext line" )); runtime @@ -4618,6 +4736,104 @@ mod tests { ); } + #[tokio::test] + async fn bundled_agent_insert_control_enter_dispatches_the_complete_docked_prompt() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + + for code in [KeyCode::Enter, KeyCode::Char('\n'), KeyCode::Char('\r')] { + for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + runtime + .notify( + "agent:session_created", + serde_json::json!({ "session_id": "session-1" }), + ) + .await + .unwrap(); + drain_requests(); + + let mut panels = PanelManager::default(); + panels.create_text_panel( + "agent-conversation".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 40, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up".to_string(), + rows: 3, + }), + ..PanelConfig::default() + }, + ); + assert!(panels.focus_text_panel_composer("agent-conversation")); + panels.handle_focused_text_input( + &Event::Paste("first\r\n漢👨‍👩‍👧\r\nsecond".to_string()), + 80, + ); + + let submitted = panels + .handle_focused_text_input( + &Event::Key(KeyEvent::new_with_kind(code, KeyModifiers::CONTROL, kind)), + 80, + ) + .unwrap_or_else(|| { + panic!("{code:?} with Ctrl and {kind:?} must submit the Insert draft") + }); + assert_eq!(submitted.panel_id, "agent-conversation"); + assert_eq!(submitted.action, "submit"); + assert_eq!( + submitted.text.as_deref(), + Some("first\n漢👨‍👩‍👧\nsecond") + ); + + runtime + .notify( + "panel:event:agent-conversation", + serde_json::to_value(&submitted).unwrap(), + ) + .await + .unwrap(); + + let mut rendered = false; + let mut refreshed = false; + let mut dispatched = false; + while let Some(request) = ACTION_DISPATCHER.try_recv_request() { + match request { + PluginRequest::UpdateTextPanel { id, blocks } => { + rendered |= id == "agent-conversation" + && blocks + .iter() + .any(|block| block.text == "first\n漢👨‍👩‍👧\nsecond"); + } + PluginRequest::Action(Action::Refresh) => refreshed = true, + PluginRequest::AgentPrompt { session_id, text } => { + assert!( + rendered && refreshed, + "the submitted conversation must render before dispatch" + ); + assert_eq!(session_id, "session-1"); + assert_eq!(text, "first\n漢👨‍👩‍👧\nsecond"); + dispatched = true; + } + _ => {} + } + } + + assert!( + dispatched, + "{code:?} with Ctrl and {kind:?} must reach the bundled agent backend" + ); + assert!(panels.focused_text_input_active()); + } + } + } + #[tokio::test] async fn bundled_agent_panel_submits_and_drains_followups_in_fifo_order() { let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; @@ -4968,6 +5184,338 @@ mod tests { assert!(ACTION_DISPATCHER.try_recv_request().is_none()); } + #[tokio::test] + async fn bundled_agent_docking_commands_preserve_and_focus_the_conversation() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + + for (command, expected_position) in [ + ("AgentLeft", crate::config::AgentPosition::Left), + ("AgentRight", crate::config::AgentPosition::Right), + ("AgentTop", crate::config::AgentPosition::Top), + ("AgentBottom", crate::config::AgentPosition::Bottom), + ] { + runtime.execute_command(command).await.unwrap(); + + let mut moved = false; + let mut focused = false; + while let Some(request) = ACTION_DISPATCHER.try_recv_request() { + match request { + PluginRequest::SetAgentPosition { position } => { + assert_eq!(position, expected_position); + moved = true; + } + PluginRequest::FocusPanel { id } => { + focused |= id == "agent-conversation"; + } + PluginRequest::CreateTextPanel { id, .. } + | PluginRequest::UpdateTextPanel { id, .. } => { + assert_eq!(id, "agent-conversation"); + } + _ => panic!("unexpected host effect while moving {command}"), + } + } + + assert!(moved, "{command} must update the existing dock position"); + assert!(focused, "{command} must focus the existing conversation"); + } + } + + #[tokio::test] + async fn bundled_agent_catalog_commands_request_the_active_codex_session() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + + runtime + .notify( + "agent:session_created", + serde_json::json!({ "session_id": "session-catalog" }), + ) + .await + .unwrap(); + drain_requests(); + + runtime.execute_command("AgentModels").await.unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::AgentListModels { session_id } + if session_id == "session-catalog" + )); + + runtime.execute_command("AgentSessions").await.unwrap(); + let request_id = match ACTION_DISPATCHER.recv_request() { + PluginRequest::GetConfig { request_id, key } => { + assert_eq!(key.as_deref(), Some("cwd")); + request_id + } + _ => panic!("expected a workspace root request for saved conversations"), + }; + runtime + .resolve_request(request_id, serde_json::json!({ "value": "/workspace" })) + .await + .unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::AgentListSessions { session_id, cwd } + if session_id == "session-catalog" && cwd == std::path::Path::new("/workspace") + )); + } + + #[tokio::test] + async fn bundled_agent_docked_entry_opens_and_focuses_the_persistent_composer() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + + runtime + .notify("agent:open_dock", serde_json::json!({})) + .await + .unwrap(); + + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::CreateTextPanel { id, config } + if id == "agent-conversation" + && config.composer.is_some() + && config.surface.is_some() + && config.border.is_some() + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::UpdateTextPanel { id, .. } + if id == "agent-conversation" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::FocusTextPanelComposer { id } + if id == "agent-conversation" + )); + assert!( + ACTION_DISPATCHER.try_recv_request().is_none(), + "docked entry must not open a floating composer or start Codex early" + ); + } + + #[tokio::test] + async fn bundled_agent_model_catalog_opens_a_real_model_picker() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + runtime + .notify( + "agent:session_created", + serde_json::json!({ "session_id": "session-models" }), + ) + .await + .unwrap(); + drain_requests(); + + runtime + .notify( + "agent:activity", + serde_json::json!({ + "session_id": "session-models", + "update": { + "session_update": "models", + "models": [{ + "id": "gpt-5.4", + "display_name": "GPT-5.4", + "description": "General-purpose coding model", + "default_reasoning_effort": "high", + }], + }, + }), + ) + .await + .unwrap(); + + let (picker, items) = recv_agent_picker("Codex model"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "gpt-5.4"); + assert_eq!(items[0].label, "GPT-5.4"); + + runtime + .notify_picker(picker, PickerCallback::Selected(items[0].clone())) + .unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::AgentSetModel { + session_id, + model, + reasoning_effort, + } if session_id == "session-models" + && model == "gpt-5.4" + && reasoning_effort.as_deref() == Some("high") + )); + assert!( + ACTION_DISPATCHER.try_recv_request().is_none(), + "model selection must preserve the active Codex thread" + ); + } + + #[tokio::test] + async fn bundled_agent_reports_confirmed_model_changes_without_resetting_the_session() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + runtime + .notify( + "agent:session_created", + serde_json::json!({ "session_id": "session-models" }), + ) + .await + .unwrap(); + drain_requests(); + + runtime + .notify( + "agent:activity", + serde_json::json!({ + "session_id": "session-models", + "update": { + "session_update": "model_selected", + "model": "gpt-5.4", + "reasoning_effort": "high", + }, + }), + ) + .await + .unwrap(); + + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::Action(Action::Print(message)) + if message == "Codex model set to gpt-5.4" + )); + assert!(ACTION_DISPATCHER.try_recv_request().is_none()); + } + + #[tokio::test] + async fn bundled_agent_steering_submits_instructions_to_the_current_turn() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + runtime + .notify( + "agent:session_created", + serde_json::json!({ "session_id": "session-steer" }), + ) + .await + .unwrap(); + drain_requests(); + runtime + .notify( + "panel:event:agent-conversation", + serde_json::json!({ "action": "submit", "text": "inspect the workspace" }), + ) + .await + .unwrap(); + drain_requests(); + + runtime.execute_command("AgentSteer").await.unwrap(); + let (composer, title, _, _) = recv_agent_composer(); + assert_eq!(title.as_deref(), Some("Steer agent")); + assert!(runtime + .notify_composer( + composer, + ComposerCallback::Submitted("focus on unsaved changes".to_string()), + ) + .unwrap()); + + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::AgentSteer { session_id, text } + if session_id == "session-steer" && text == "focus on unsaved changes" + )); + assert!(ACTION_DISPATCHER.try_recv_request().is_none()); + } + + #[tokio::test] + async fn bundled_agent_session_catalog_resumes_the_selected_workspace_thread() { + let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; + drain_requests(); + let mut runtime = Runtime::new(); + runtime + .load_plugin("agent", include_str!("../../plugins/agent.hk")) + .await + .unwrap(); + runtime + .notify( + "agent:session_created", + serde_json::json!({ "session_id": "session-current" }), + ) + .await + .unwrap(); + drain_requests(); + + runtime + .notify( + "agent:activity", + serde_json::json!({ + "session_id": "session-current", + "update": { + "session_update": "sessions", + "sessions": [{ + "id": "session-saved", + "preview": "Fix the agent composer", + }], + }, + }), + ) + .await + .unwrap(); + + let (picker, items) = recv_agent_picker("Agent conversations"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "session-saved"); + + runtime + .notify_picker(picker, PickerCallback::Selected(items[0].clone())) + .unwrap(); + let request_id = match ACTION_DISPATCHER.recv_request() { + PluginRequest::GetConfig { request_id, key } => { + assert_eq!(key.as_deref(), Some("cwd")); + request_id + } + _ => panic!("resuming a conversation must request the workspace root"), + }; + runtime + .resolve_request(request_id, serde_json::json!({ "value": "/workspace" })) + .await + .unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::AgentResumeSession { session_id, cwd } + if session_id == "session-saved" && cwd == std::path::Path::new("/workspace") + )); + } + #[tokio::test] async fn bundled_agent_close_reopens_without_recreating_and_new_resets_the_session() { let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; diff --git a/src/session.rs b/src/session.rs index bd0b1124..ed0284a8 100644 --- a/src/session.rs +++ b/src/session.rs @@ -106,6 +106,9 @@ pub struct SessionSnapshot { /// Human-readable agent transcript retained across recovery. #[serde(default)] pub agent_transcript: Option, + /// Persisted Codex thread that can be resumed in the original workspace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_thread_id: Option, /// Pending agent proposal state, including review dispositions. #[serde(default)] pub agent_workspace: Option, @@ -2255,6 +2258,7 @@ mod tests { global_marks: Vec::new(), special_marks: Vec::new(), agent_transcript: None, + agent_thread_id: None, agent_workspace: None, agent_session_resumable: false, } diff --git a/src/ui/agent_composer.rs b/src/ui/agent_composer.rs index b729c352..0b7e5af2 100644 --- a/src/ui/agent_composer.rs +++ b/src/ui/agent_composer.rs @@ -6,21 +6,26 @@ use unicode_segmentation::UnicodeSegmentation; use crate::{ config::KeyAction, - editor::{Action, ComposerCallback, Editor, RenderBuffer}, + editor::{Action, ComposerCallback, Editor, Mode, RenderBuffer}, plugin::ComposerHandle, theme::{Style, Theme}, - unicode_utils::{display_width, grapheme_len, grapheme_to_byte, truncate_display_width}, + unicode_utils::{display_width, grapheme_len, truncate_display_width}, }; use super::{ dialog::{BorderStyle, Dialog}, - Component, + Component, ModalComposer, ModalComposerMode, ModalComposerOutcome, }; const TAB_WIDTH: usize = 4; -const MAX_PROMPT_BYTES: usize = 128 * 1024; -const STATUS: &str = "Enter ^J newline Esc ^P/N"; +const INSERT_HINTS: &str = " Ctrl+Enter send; Esc normal; Enter line"; +const NORMAL_HINTS: &str = " Ctrl+Enter send; i edit; Enter send"; +const VISUAL_HINTS: &str = " Ctrl+Enter send; Esc normal; hjkl select"; +#[cfg(test)] +const STATUS: &str = "INSERT Ctrl+Enter send; Esc normal"; +#[cfg(test)] const EMPTY_STATUS: &str = "Prompt is empty"; +#[cfg(test)] const OVERSIZED_STATUS: &str = "Prompt exceeds 128 KiB"; #[derive(Debug)] @@ -33,17 +38,14 @@ pub(crate) struct WrappedText { pub struct AgentComposer { target: ComposerTarget, dialog: Dialog, - query: String, - cursor: usize, - history: Vec, - history_position: Option, - history_draft: Option, - preferred_column: Option, - validation_status: Option<&'static str>, + title: Option, + composer: ModalComposer, + ascii_borders: bool, viewport_width: usize, viewport_height: usize, style: Style, muted_style: Style, + footer_style: Style, theme: Theme, } @@ -54,7 +56,7 @@ enum ComposerTarget { } impl AgentComposer { - /// Creates a right-aligned composer with the cursor at the end of `query`. + /// Creates a centered, Vim-capable composer with the cursor at the end of `query`. pub fn new( editor: &Editor, title: Option, @@ -97,25 +99,15 @@ impl AgentComposer { target: ComposerTarget, ) -> Self { let theme = editor.theme.clone(); - let style = theme.ui_style.popup.clone(); - let border_style = theme.ui_style.popup_border.clone(); - let title_style = theme.ui_style.popup_title.clone(); + let style = theme.ui_style.popup.with_bg(theme.style.bg); + let border_style = theme.ui_style.popup_border.with_bg(theme.style.bg); + let title_style = theme.ui_style.popup_title.with_bg(theme.style.bg); + let popup_title = title.clone(); + let ascii_borders = editor.window_borders_ascii(); let viewport_width = editor.vwidth(); let viewport_height = editor.vheight(); let (x, y, width, height) = Self::geometry(viewport_width, viewport_height); - let initial_too_large = query.len() > MAX_PROMPT_BYTES; - let query = if initial_too_large { - String::new() - } else { - normalize_newlines(&query) - }; - let history_len = history.len(); - let history = history - .into_iter() - .filter(|entry| entry.len() <= MAX_PROMPT_BYTES) - .collect::>(); - let history_too_large = history.len() != history_len; - let cursor = grapheme_len(&query); + let composer = ModalComposer::new(&query, history); Self { target, @@ -131,17 +123,14 @@ impl AgentComposer { ) .with_border_draw_style(&border_style) .with_title_style(&title_style), - query, - cursor, - history, - history_position: None, - history_draft: None, - preferred_column: None, - validation_status: (initial_too_large || history_too_large).then_some(OVERSIZED_STATUS), + title: popup_title, + composer, + ascii_borders, viewport_width, viewport_height, style, - muted_style: theme.ui_style.muted.clone(), + muted_style: theme.ui_style.muted.with_bg(theme.style.bg), + footer_style: theme.ui_style.muted.clone(), theme, } } @@ -170,13 +159,13 @@ impl AgentComposer { Action::NotifyPlugin( owner.clone(), format!("composer:submitted:{id}"), - json!(self.query), + json!(self.composer.contents()), ), ]), ComposerTarget::Callback(handle) => KeyAction::Multiple(vec![ Action::NotifyComposer( *handle, - Box::new(ComposerCallback::Submitted(self.query.clone())), + Box::new(ComposerCallback::Submitted(self.composer.contents())), ), Action::CloseDialog, ]), @@ -190,7 +179,7 @@ impl AgentComposer { let outer_height = (viewport_height * 65 / 100) .clamp(8, 18) .min(viewport_height); - let x = viewport_width.saturating_sub(outer_width); + let x = viewport_width.saturating_sub(outer_width) / 2; let y = viewport_height.saturating_sub(outer_height) / 2; ( x, @@ -209,146 +198,54 @@ impl AgentComposer { } fn wrapped_text(&self) -> WrappedText { - wrap_text(&self.query, self.dialog.width) + wrap_text(&self.composer.contents(), self.dialog.width) } - fn insert(&mut self, text: &str) { - if text.len() > MAX_PROMPT_BYTES.saturating_sub(self.query.len()) { - self.validation_status = Some(OVERSIZED_STATUS); + fn draw_border(&self, buffer: &mut RenderBuffer) { + if buffer.width == 0 || buffer.height == 0 { return; } - let text = normalize_newlines(text); - if text.is_empty() { - return; - } - - let offset = grapheme_to_byte(&self.query, self.cursor); - self.query.insert_str(offset, &text); - self.cursor = self.query[..offset + text.len()].graphemes(true).count(); - self.preferred_column = None; - self.validation_status = None; - self.history_position = None; - self.history_draft = None; - } - - fn backspace(&mut self) { - if self.cursor == 0 { - return; - } - let start = grapheme_to_byte(&self.query, self.cursor - 1); - let end = grapheme_to_byte(&self.query, self.cursor); - self.query.replace_range(start..end, ""); - self.cursor -= 1; - self.preferred_column = None; - self.validation_status = None; - self.history_position = None; - self.history_draft = None; - } - - fn delete(&mut self) { - if self.cursor >= grapheme_len(&self.query) { - return; - } - let start = grapheme_to_byte(&self.query, self.cursor); - let end = grapheme_to_byte(&self.query, self.cursor + 1); - self.query.replace_range(start..end, ""); - self.preferred_column = None; - self.validation_status = None; - self.history_position = None; - self.history_draft = None; - } - fn delete_previous_word(&mut self) { - if self.cursor == 0 { - return; - } - let end = grapheme_to_byte(&self.query, self.cursor); - let before = &self.query[..end]; - let mut start = self.cursor; - let mut seen_word = false; - - for grapheme in before.graphemes(true).rev() { - let whitespace = grapheme.chars().all(char::is_whitespace); - if seen_word && whitespace { - break; - } - seen_word |= !whitespace; - start -= 1; - } - - let start_byte = grapheme_to_byte(&self.query, start); - self.query.replace_range(start_byte..end, ""); - self.cursor = start; - self.preferred_column = None; - self.validation_status = None; - self.history_position = None; - self.history_draft = None; - } - - fn move_vertically(&mut self, direction: isize) { - let wrapped = self.wrapped_text(); - let Some(&(row, column)) = wrapped.positions.get(self.cursor) else { - return; + let x = self.dialog.x; + let y = self.dialog.y; + let width = self.dialog.width.saturating_add(2); + let height = self.dialog.height.saturating_add(2); + let right = x.saturating_add(width.saturating_sub(1)); + let bottom = y.saturating_add(height.saturating_sub(1)); + let (horizontal, vertical, corners) = if self.ascii_borders { + ('-', '|', ['+', '+', '+', '+']) + } else { + ('─', '│', ['┌', '┐', '└', '┘']) }; - let target_row = row.saturating_add_signed(direction); - if target_row >= wrapped.rows.len() || target_row == row { - return; - } - let goal = *self.preferred_column.get_or_insert(column); - let mut target = None; - let mut distance = usize::MAX; - - for (index, &(candidate_row, candidate_column)) in wrapped.positions.iter().enumerate() { - if candidate_row != target_row { - continue; - } - let candidate_distance = candidate_column.abs_diff(goal); - if candidate_distance < distance { - target = Some(index); - distance = candidate_distance; + let style = &self.dialog.border_draw_style; + + buffer.fill_rect(x, y, width, 1, horizontal, style, &self.theme); + buffer.fill_rect(x, bottom, width, 1, horizontal, style, &self.theme); + buffer.fill_rect(x, y, 1, height, vertical, style, &self.theme); + buffer.fill_rect(right, y, 1, height, vertical, style, &self.theme); + buffer.set_char(x, y, corners[0], style, &self.theme); + buffer.set_char(right, y, corners[1], style, &self.theme); + buffer.set_char(x, bottom, corners[2], style, &self.theme); + buffer.set_char(right, bottom, corners[3], style, &self.theme); + + if let Some(title) = &self.title { + let available = width.saturating_sub(2); + if available > 0 { + let title = format!(" {title} "); + let title = truncate_display_width(&title, available); + let offset = available.saturating_sub(display_width(&title)) / 2; + let title_x = x.saturating_add(1).saturating_add(offset); + buffer.set_text(title_x, y, &title, &self.dialog.title_style); } } - - if let Some(target) = target { - self.cursor = target; - } - } - - fn history_back(&mut self) { - if self.history.is_empty() { - return; - } - let position = match self.history_position { - Some(position) => (position + 1).min(self.history.len() - 1), - None => { - self.history_draft = Some(self.query.clone()); - 0 - } - }; - self.history_position = Some(position); - self.query.clone_from(&self.history[position]); - self.query = normalize_newlines(&self.query); - self.cursor = grapheme_len(&self.query); - self.preferred_column = None; - self.validation_status = None; } - fn history_forward(&mut self) { - let Some(position) = self.history_position else { - return; - }; - if position > 0 { - let next = position - 1; - self.history_position = Some(next); - self.query.clone_from(&self.history[next]); - self.query = normalize_newlines(&self.query); - } else { - self.history_position = None; - self.query = self.history_draft.take().unwrap_or_default(); + fn composer_action(&self, outcome: ModalComposerOutcome) -> Option { + match outcome { + ModalComposerOutcome::Submit => Some(self.submit_action()), + ModalComposerOutcome::Changed | ModalComposerOutcome::Rejected => Self::redraw(), + ModalComposerOutcome::Unhandled => None, } - self.cursor = grapheme_len(&self.query); - self.preferred_column = None; - self.validation_status = None; } fn redraw() -> Option { @@ -366,36 +263,7 @@ impl Component for AgentComposer { fn draw(&self, buffer: &mut RenderBuffer) -> anyhow::Result<()> { self.dialog.draw(buffer)?; - let right = self.dialog.x + self.dialog.width + 1; - let bottom = self.dialog.y + self.dialog.height + 1; - buffer.set_char( - self.dialog.x, - self.dialog.y, - '┌', - &self.dialog.border_draw_style, - &self.theme, - ); - buffer.set_char( - right, - self.dialog.y, - '┐', - &self.dialog.border_draw_style, - &self.theme, - ); - buffer.set_char( - self.dialog.x, - bottom, - '└', - &self.dialog.border_draw_style, - &self.theme, - ); - buffer.set_char( - right, - bottom, - '┘', - &self.dialog.border_draw_style, - &self.theme, - ); + self.draw_border(buffer); let body_height = self.body_height(); let content_x = self.dialog.x + 1; let content_y = self.dialog.y + 1; @@ -403,7 +271,7 @@ impl Component for AgentComposer { return Ok(()); } - if self.query.is_empty() { + if self.composer.contents().is_empty() { let placeholder = truncate_display_width("What should the agent do?", self.dialog.width); buffer.set_text(content_x, content_y, &placeholder, &self.muted_style); @@ -411,7 +279,7 @@ impl Component for AgentComposer { let wrapped = self.wrapped_text(); let cursor_row = wrapped .positions - .get(self.cursor) + .get(self.composer.cursor_grapheme_index()) .map_or(0, |position| position.0); let scroll = cursor_row.saturating_sub(body_height - 1); for (offset, row) in wrapped @@ -427,9 +295,25 @@ impl Component for AgentComposer { if self.dialog.height > body_height { let status_y = content_y + body_height; - let status = self.validation_status.unwrap_or(STATUS); + let mode = self.composer.mode(); + let shortcuts = match mode { + ModalComposerMode::Insert => INSERT_HINTS, + ModalComposerMode::Normal => NORMAL_HINTS, + ModalComposerMode::Visual => VISUAL_HINTS, + }; + let mode_status = format!("{}{shortcuts}", mode.label()); + let status = self + .composer + .validation_status() + .unwrap_or(mode_status.as_str()); let status = truncate_display_width(status, self.dialog.width); - buffer.set_text(content_x, status_y, &status, &self.muted_style); + buffer.set_text( + content_x, + status_y, + &" ".repeat(self.dialog.width), + &self.footer_style, + ); + buffer.set_text(content_x, status_y, &status, &self.footer_style); } Ok(()) } @@ -437,117 +321,19 @@ impl Component for AgentComposer { fn handle_event(&mut self, event: &Event) -> Option { match event { Event::Paste(text) => { - self.insert(text); - Self::redraw() + let outcome = self.composer.handle_paste(text); + self.composer_action(outcome) + } + Event::Key(key) + if matches!(key.code, KeyCode::Char('c' | 'C')) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + Some(self.cancel_action()) + } + Event::Key(key) => { + let outcome = self.composer.handle_key(*key); + self.composer_action(outcome) } - Event::Key(key) => match (key.code, key.modifiers) { - (KeyCode::Esc, _) => Some(self.cancel_action()), - (KeyCode::Char('c' | 'C'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - Some(self.cancel_action()) - } - (KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::SHIFT) => { - self.insert("\n"); - Self::redraw() - } - (KeyCode::Enter, _) => { - if self.query.len() > MAX_PROMPT_BYTES { - self.validation_status = Some(OVERSIZED_STATUS); - return Self::redraw(); - } - if self.query.trim().is_empty() { - self.validation_status = Some(EMPTY_STATUS); - return Self::redraw(); - } - Some(self.submit_action()) - } - (KeyCode::Char('j' | 'J'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.insert("\n"); - Self::redraw() - } - (KeyCode::Char('p' | 'P'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.history_back(); - Self::redraw() - } - (KeyCode::Char('n' | 'N'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.history_forward(); - Self::redraw() - } - (KeyCode::Char('w' | 'W'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.delete_previous_word(); - Self::redraw() - } - (KeyCode::Home, _) => { - self.cursor = 0; - self.preferred_column = None; - Self::redraw() - } - (KeyCode::Char('a' | 'A'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.cursor = 0; - self.preferred_column = None; - Self::redraw() - } - (KeyCode::End, _) => { - self.cursor = grapheme_len(&self.query); - self.preferred_column = None; - Self::redraw() - } - (KeyCode::Char('e' | 'E'), modifiers) - if modifiers.contains(KeyModifiers::CONTROL) => - { - self.cursor = grapheme_len(&self.query); - self.preferred_column = None; - Self::redraw() - } - (KeyCode::Left, _) => { - self.cursor = self.cursor.saturating_sub(1); - self.preferred_column = None; - Self::redraw() - } - (KeyCode::Right, _) => { - self.cursor = (self.cursor + 1).min(grapheme_len(&self.query)); - self.preferred_column = None; - Self::redraw() - } - (KeyCode::Up, _) => { - self.move_vertically(-1); - Self::redraw() - } - (KeyCode::Down, _) => { - self.move_vertically(1); - Self::redraw() - } - (KeyCode::Backspace, _) => { - self.backspace(); - Self::redraw() - } - (KeyCode::Delete, _) => { - self.delete(); - Self::redraw() - } - (KeyCode::Tab, _) => { - self.insert("\t"); - Self::redraw() - } - (KeyCode::Char(character), modifiers) - if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - self.insert(&character.to_string()); - Self::redraw() - } - _ => None, - }, _ => None, } } @@ -560,16 +346,16 @@ impl Component for AgentComposer { self.dialog.height = height; self.viewport_width = viewport_width; self.viewport_height = viewport_height; - self.preferred_column = None; true } fn set_theme(&mut self, theme: &Theme) { - self.style = theme.ui_style.popup.clone(); - self.muted_style = theme.ui_style.muted.clone(); - self.dialog.style = theme.ui_style.popup.clone(); - self.dialog.border_draw_style = theme.ui_style.popup_border.clone(); - self.dialog.title_style = theme.ui_style.popup_title.clone(); + self.style = theme.ui_style.popup.with_bg(theme.style.bg); + self.muted_style = theme.ui_style.muted.with_bg(theme.style.bg); + self.footer_style = theme.ui_style.muted.clone(); + self.dialog.style = self.style.clone(); + self.dialog.border_draw_style = theme.ui_style.popup_border.with_bg(theme.style.bg); + self.dialog.title_style = theme.ui_style.popup_title.with_bg(theme.style.bg); self.dialog.theme = theme.clone(); self.theme = theme.clone(); } @@ -578,11 +364,22 @@ impl Component for AgentComposer { true } + fn cursor_mode(&self) -> Option { + Some(match self.composer.mode() { + ModalComposerMode::Insert => Mode::Insert, + ModalComposerMode::Normal => Mode::Normal, + ModalComposerMode::Visual => Mode::Visual, + }) + } + fn cursor_position(&self) -> Option<(usize, usize)> { + if self.viewport_width == 0 || self.viewport_height == 0 { + return None; + } let wrapped = self.wrapped_text(); let (row, column) = wrapped .positions - .get(self.cursor) + .get(self.composer.cursor_grapheme_index()) .copied() .unwrap_or_default(); let body_height = self.body_height(); @@ -603,10 +400,6 @@ impl Component for AgentComposer { } } -pub(crate) fn normalize_newlines(text: &str) -> String { - text.replace("\r\n", "\n").replace('\r', "\n") -} - pub(crate) fn wrap_text(text: &str, width: usize) -> WrappedText { let grapheme_count = grapheme_len(text); if width == 0 { @@ -691,10 +484,16 @@ mod tests { use serde_json::json; use super::*; - use crate::{buffer::Buffer, config::Config, lsp::LspManager}; + use crate::{ + buffer::Buffer, config::Config, lsp::LspManager, theme::parse_vscode_theme, + ui::modal_composer::MAX_PROMPT_BYTES, + }; fn editor(width: usize, height: usize) -> Editor { - let config = Config::default(); + editor_with_config(width, height, Config::default()) + } + + fn editor_with_config(width: usize, height: usize, config: Config) -> Editor { Editor::with_size( Box::new(LspManager::new(config.lsp.clone())), width, @@ -710,8 +509,12 @@ mod tests { Event::Key(KeyEvent::new(code, modifiers)) } + fn modified_enter_codes() -> [KeyCode; 3] { + [KeyCode::Enter, KeyCode::Char('\n'), KeyCode::Char('\r')] + } + fn submit(composer: &mut AgentComposer) -> Option { - composer.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)) + composer.handle_event(&key(KeyCode::Enter, KeyModifiers::CONTROL)) } fn new_composer( @@ -731,6 +534,199 @@ mod tests { .collect() } + #[test] + fn floating_composer_keeps_the_editor_background_and_one_accented_footer() { + let editor = editor(80, 24); + + for theme_path in ["themes/kanso.json", "themes/github-light.json"] { + let theme = parse_vscode_theme(theme_path).unwrap(); + assert_ne!( + theme.ui_style.muted.bg, theme.style.bg, + "{theme_path} should exercise a visibly accented footer", + ); + + for draft in ["", "Explain the current file"] { + let mut composer = new_composer( + &editor, + Some("Agent prompt".to_string()), + 802, + draft.to_string(), + vec![], + ); + composer.set_theme(&theme); + + let mut buffer = RenderBuffer::new(80, editor.vheight(), &theme.style); + composer.draw(&mut buffer).unwrap(); + + let left = composer.dialog.x; + let right = left + composer.dialog.width + 1; + let top = composer.dialog.y; + let bottom = top + composer.dialog.height + 1; + let footer_y = top + 1 + composer.body_height(); + + for y in top..=bottom { + for x in left..=right { + let footer_content = y == footer_y && x > left && x < right; + let expected_background = if footer_content { + theme.ui_style.muted.bg + } else { + theme.style.bg + }; + assert_eq!( + buffer.cells[y * buffer.width + x].style.bg, + expected_background, + "{theme_path}, draft={draft:?}, x={x}, y={y}", + ); + } + } + + assert_eq!( + buffer.cells[top * buffer.width + left].style.fg, + theme.ui_style.popup_border.fg, + "floating outlines should retain their theme accent", + ); + + let body = &buffer.cells[(top + 1) * buffer.width + left + 1]; + let expected_foreground = if draft.is_empty() { + theme.ui_style.muted.fg + } else { + theme.ui_style.popup.fg + }; + assert_eq!(body.style.fg, expected_foreground); + assert!(rendered_row(&buffer, footer_y).contains("Ctrl+Enter send")); + } + } + } + + #[test] + fn floating_composer_reports_its_vim_cursor_mode_and_wrapped_position() { + let editor = editor(16, 12); + let mut composer = new_composer( + &editor, + Some("Agent prompt".to_string()), + 802, + "wide 漢 👨‍👩‍👧\nsecond e\u{301} line".to_string(), + vec![], + ); + + let assert_cursor = |composer: &AgentComposer, expected_mode: Mode| { + assert_eq!(Component::cursor_mode(composer), Some(expected_mode)); + + let wrapped = composer.wrapped_text(); + let (row, column) = wrapped.positions[composer.composer.cursor_grapheme_index()]; + let scroll = row.saturating_sub(composer.body_height().saturating_sub(1)); + + assert_eq!( + composer.cursor_position(), + Some(( + composer.dialog.x + 1 + column, + composer.dialog.y + 1 + row.saturating_sub(scroll), + )), + ); + }; + + assert_cursor(&composer, Mode::Insert); + + composer.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)); + assert_cursor(&composer, Mode::Normal); + + composer.handle_event(&key(KeyCode::Char('v'), KeyModifiers::NONE)); + assert_cursor(&composer, Mode::Visual); + + composer.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)); + assert_cursor(&composer, Mode::Normal); + + composer.handle_event(&key(KeyCode::Char('i'), KeyModifiers::NONE)); + assert_cursor(&composer, Mode::Insert); + } + + #[test] + fn modified_enter_submits_the_complete_floating_prompt_in_every_vim_mode() { + let editor = editor(60, 18); + let modifiers = [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + KeyModifiers::ALT | KeyModifiers::SHIFT, + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, + ]; + + for code in modified_enter_codes() { + for modifiers in modifiers { + for mode in [ + ModalComposerMode::Insert, + ModalComposerMode::Normal, + ModalComposerMode::Visual, + ] { + let mut composer = new_composer( + &editor, + Some("Agent prompt".to_string()), + 802, + "first\n漢👨‍👩‍👧".to_string(), + vec![], + ); + + if mode != ModalComposerMode::Insert { + composer.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)); + if mode == ModalComposerMode::Visual { + composer.handle_event(&key(KeyCode::Char('v'), KeyModifiers::NONE)); + } + } + + assert_eq!(composer.composer.mode(), mode); + assert_eq!( + composer.handle_event(&key(code, modifiers)), + Some(KeyAction::Multiple(vec![ + Action::CloseDialog, + Action::NotifyPlugin( + "agent".to_string(), + "composer:submitted:802".to_string(), + json!("first\n漢👨‍👩‍👧"), + ), + ])), + "{code:?} should submit in {mode:?} with {modifiers:?}", + ); + assert_eq!(composer.composer.contents(), "first\n漢👨‍👩‍👧"); + } + } + } + } + + #[test] + fn control_s_does_not_submit_or_change_the_floating_prompt() { + let editor = editor(60, 18); + + for mode in [ + ModalComposerMode::Insert, + ModalComposerMode::Normal, + ModalComposerMode::Visual, + ] { + let mut composer = new_composer( + &editor, + Some("Agent prompt".to_string()), + 802, + "preserve this draft".to_string(), + vec![], + ); + + if mode != ModalComposerMode::Insert { + composer.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)); + if mode == ModalComposerMode::Visual { + composer.handle_event(&key(KeyCode::Char('v'), KeyModifiers::NONE)); + } + } + + assert_eq!(composer.composer.mode(), mode); + assert_eq!( + composer.handle_event(&key(KeyCode::Char('s'), KeyModifiers::CONTROL)), + None, + "Ctrl+S must not submit in {mode:?}", + ); + assert_eq!(composer.composer.contents(), "preserve this draft"); + assert_eq!(composer.composer.mode(), mode); + } + } + #[test] fn overflowing_prompt_wraps_and_keeps_cursor_inside_the_dialog() { let editor = editor(80, 24); @@ -771,7 +767,10 @@ mod tests { "first\tline\r\n second\rthird\n".to_string(), )); - assert_eq!(composer.query, "first\tline\n second\nthird\n"); + assert_eq!( + composer.composer.contents(), + "first\tline\n second\nthird\n" + ); let wrapped = composer.wrapped_text(); assert_eq!(wrapped.rows[0], "first line"); assert_eq!(wrapped.rows[1], " second"); @@ -800,22 +799,28 @@ mod tests { composer.handle_event(&key(KeyCode::Delete, KeyModifiers::NONE)); composer.handle_event(&key(KeyCode::Char('q'), KeyModifiers::CONTROL)); composer.handle_event(&key(KeyCode::Char('z'), KeyModifiers::ALT)); - assert_eq!(composer.query, "one tXo"); + assert_eq!(composer.composer.contents(), "one tXo"); composer.handle_event(&key(KeyCode::Char('w'), KeyModifiers::CONTROL)); - assert_eq!(composer.query, "one o"); - composer.handle_event(&key(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(composer.composer.contents(), "one o"); + composer.handle_event(&key(KeyCode::Home, KeyModifiers::NONE)); composer.handle_event(&key(KeyCode::Delete, KeyModifiers::NONE)); - assert_eq!(composer.query, "ne o"); - composer.handle_event(&key(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(composer.composer.contents(), "ne o"); + composer.handle_event(&key(KeyCode::End, KeyModifiers::NONE)); composer.handle_event(&key(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.query, "ne "); + assert_eq!(composer.composer.contents(), "ne "); } #[test] - fn newline_shortcuts_and_vertical_motion_work_on_wrapped_lines() { + fn newline_shortcuts_and_vertical_motion_work_on_multiline_buffers() { let editor = editor(40, 14); - let mut composer = new_composer(&editor, None, 1, "a".repeat(40), vec![]); + let mut composer = new_composer( + &editor, + None, + 1, + format!("{}\n{}", "a".repeat(20), "b".repeat(20)), + vec![], + ); let (_, original_row) = composer.cursor_position().unwrap(); composer.handle_event(&key(KeyCode::Up, KeyModifiers::NONE)); @@ -827,7 +832,7 @@ mod tests { composer.handle_event(&key(KeyCode::Enter, KeyModifiers::SHIFT)); composer.handle_event(&key(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(composer.query.ends_with("\nx\ny")); + assert!(composer.composer.contents().ends_with("\nx\ny")); } #[test] @@ -836,12 +841,12 @@ mod tests { let mut composer = new_composer(&editor, None, 9, "e\u{301}👨‍👩‍👧漢".to_string(), vec![]); composer.handle_event(&key(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.query, "e\u{301}👨‍👩‍👧"); + assert_eq!(composer.composer.contents(), "e\u{301}👨‍👩‍👧"); composer.handle_event(&key(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.query, "e\u{301}"); + assert_eq!(composer.composer.contents(), "e\u{301}"); composer.handle_event(&key(KeyCode::Home, KeyModifiers::NONE)); composer.handle_event(&key(KeyCode::Delete, KeyModifiers::NONE)); - assert!(composer.query.is_empty()); + assert!(composer.composer.contents().is_empty()); } #[test] @@ -856,13 +861,13 @@ mod tests { ); composer.handle_event(&key(KeyCode::Char('p'), KeyModifiers::CONTROL)); - assert_eq!(composer.query, "newer\nprompt"); + assert_eq!(composer.composer.contents(), "newer\nprompt"); composer.handle_event(&key(KeyCode::Char('p'), KeyModifiers::CONTROL)); - assert_eq!(composer.query, "older"); + assert_eq!(composer.composer.contents(), "older"); composer.handle_event(&key(KeyCode::Char('n'), KeyModifiers::CONTROL)); - assert_eq!(composer.query, "newer\nprompt"); + assert_eq!(composer.composer.contents(), "newer\nprompt"); composer.handle_event(&key(KeyCode::Char('n'), KeyModifiers::CONTROL)); - assert_eq!(composer.query, "current draft"); + assert_eq!(composer.composer.contents(), "current draft"); assert!(composer.is_sensitive_input()); assert_eq!(composer.picker_id(), None); } @@ -882,15 +887,10 @@ mod tests { assert!(rendered_row(&buffer, status_y).contains(EMPTY_STATUS)); assert_eq!( composer.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)), - Some(KeyAction::Multiple(vec![ - Action::CloseDialog, - Action::NotifyPlugin( - "agent".to_string(), - "composer:cancelled:802".to_string(), - json!(null) - ) - ])) + Some(KeyAction::Single(Action::ShowDialog)) ); + assert_eq!(composer.composer.mode(), ModalComposerMode::Normal); + assert_eq!(composer.composer.contents(), " \n\t"); assert_eq!( composer.handle_event(&key(KeyCode::Char('c'), KeyModifiers::CONTROL)), Some(KeyAction::Multiple(vec![ @@ -904,6 +904,31 @@ mod tests { ); } + #[test] + fn normalized_modified_enter_rejects_empty_floating_prompts_without_closing() { + let editor = editor(60, 18); + + for code in modified_enter_codes() { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + let mut composer = new_composer(&editor, None, 802, " \n\t".to_string(), vec![]); + + assert_eq!(composer.composer.mode(), ModalComposerMode::Insert); + assert_eq!( + composer.handle_event(&key(code, modifiers)), + Some(KeyAction::Single(Action::ShowDialog)), + "{code:?} with {modifiers:?} must keep an empty floating prompt open", + ); + assert_eq!(composer.composer.mode(), ModalComposerMode::Insert); + assert_eq!(composer.composer.contents(), " \n\t"); + assert_eq!(composer.composer.validation_status(), Some(EMPTY_STATUS)); + } + } + } + #[test] fn callback_composer_delivers_terminal_results_before_closing() { let editor = editor(60, 18); @@ -936,7 +961,7 @@ mod tests { handle, ); assert_eq!( - cancelled.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)), + cancelled.handle_event(&key(KeyCode::Char('c'), KeyModifiers::CONTROL)), Some(KeyAction::Multiple(vec![ Action::NotifyComposer(handle, Box::new(ComposerCallback::Cancelled)), Action::CloseDialog, @@ -944,6 +969,42 @@ mod tests { ); } + #[test] + fn normalized_control_enter_immediately_submits_insert_mode_composer_callbacks() { + let editor = editor(60, 18); + let handle = ComposerHandle::from_raw(42); + + for code in modified_enter_codes() { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + let mut composer = AgentComposer::new_callback( + &editor, + Some("Agent prompt".to_string()), + "first\n漢👨‍👩‍👧".to_string(), + vec![], + handle, + ); + + assert_eq!(composer.composer.mode(), ModalComposerMode::Insert); + assert_eq!( + composer.handle_event(&key(code, modifiers)), + Some(KeyAction::Multiple(vec![ + Action::NotifyComposer( + handle, + Box::new(ComposerCallback::Submitted("first\n漢👨‍👩‍👧".to_string())), + ), + Action::CloseDialog, + ])), + "{code:?} with {modifiers:?} should immediately submit the callback", + ); + assert_eq!(composer.composer.contents(), "first\n漢👨‍👩‍👧"); + } + } + } + #[test] fn resize_to_narrow_and_tiny_viewports_keeps_borders_and_cursor_in_bounds() { let editor = editor(80, 24); @@ -976,7 +1037,7 @@ mod tests { } #[test] - fn compact_status_keeps_every_shortcut_visible_at_minimum_width() { + fn compact_status_keeps_control_enter_visible_at_minimum_width() { let editor = editor(36, 14); let composer = new_composer( &editor, @@ -990,7 +1051,50 @@ mod tests { composer.draw(&mut buffer).unwrap(); let status_y = composer.dialog.y + 1 + composer.body_height(); - assert!(rendered_row(&buffer, status_y).contains(STATUS)); + let status = rendered_row(&buffer, status_y); + + assert!(status.contains(STATUS)); + assert!(!status.contains("^S")); + } + + #[test] + fn floating_status_shows_control_enter_in_every_vim_mode() { + let editor = editor(60, 18); + let mut composer = new_composer( + &editor, + Some("Agent prompt".to_string()), + 802, + "draft".to_string(), + vec![], + ); + + for mode in [ + ModalComposerMode::Insert, + ModalComposerMode::Normal, + ModalComposerMode::Visual, + ] { + if mode == ModalComposerMode::Normal { + composer.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)); + } else if mode == ModalComposerMode::Visual { + composer.handle_event(&key(KeyCode::Char('v'), KeyModifiers::NONE)); + } + + assert_eq!(composer.composer.mode(), mode); + + let mut buffer = RenderBuffer::new(60, editor.vheight(), &Style::default()); + composer.draw(&mut buffer).unwrap(); + let status_y = composer.dialog.y + 1 + composer.body_height(); + let status = rendered_row(&buffer, status_y); + + assert!( + status.contains("Ctrl+Enter send"), + "{mode:?} status should expose Ctrl+Enter: {status}", + ); + assert!( + !status.contains("^S"), + "{mode:?} status must not advertise Ctrl+S: {status}", + ); + } } #[test] @@ -1008,17 +1112,17 @@ mod tests { KeyCode::Char('P'), KeyModifiers::CONTROL | KeyModifiers::SHIFT, )); - assert_eq!(composer.query, "recent"); + assert_eq!(composer.composer.contents(), "recent"); composer.handle_event(&key( KeyCode::Char('N'), KeyModifiers::CONTROL | KeyModifiers::ALT, )); - assert_eq!(composer.query, "draft"); + assert_eq!(composer.composer.contents(), "draft"); composer.handle_event(&key( KeyCode::Char('J'), KeyModifiers::CONTROL | KeyModifiers::SHIFT, )); - assert_eq!(composer.query, "draft\n"); + assert_eq!(composer.composer.contents(), "draft\n"); } #[test] @@ -1028,18 +1132,25 @@ mod tests { let oversized = "x".repeat(MAX_PROMPT_BYTES); composer.handle_event(&Event::Paste(oversized)); - assert_eq!(composer.query, "draft"); - assert_eq!(composer.validation_status, Some(OVERSIZED_STATUS)); + assert_eq!(composer.composer.contents(), "draft"); + assert_eq!( + composer.composer.validation_status(), + Some(OVERSIZED_STATUS) + ); let mut buffer = RenderBuffer::new(60, editor.vheight(), &Style::default()); composer.draw(&mut buffer).unwrap(); let status_y = composer.dialog.y + 1 + composer.body_height(); assert!(rendered_row(&buffer, status_y).contains(OVERSIZED_STATUS)); - composer.query = "x".repeat(MAX_PROMPT_BYTES); - composer.cursor = MAX_PROMPT_BYTES; + assert!(composer + .composer + .set_contents(&"x".repeat(MAX_PROMPT_BYTES))); composer.handle_event(&key(KeyCode::Char('!'), KeyModifiers::NONE)); - assert_eq!(composer.query.len(), MAX_PROMPT_BYTES); - assert_eq!(composer.validation_status, Some(OVERSIZED_STATUS)); + assert_eq!(composer.composer.contents().len(), MAX_PROMPT_BYTES); + assert_eq!( + composer.composer.validation_status(), + Some(OVERSIZED_STATUS) + ); } #[test] @@ -1050,7 +1161,7 @@ mod tests { let encoded = serde_json::to_vec(&accepted).unwrap(); assert!(encoded.len() < 1024 * 1024); - assert_eq!(composer.query, accepted); + assert_eq!(composer.composer.contents(), accepted); assert_eq!( submit(&mut composer), Some(KeyAction::Multiple(vec![ @@ -1076,14 +1187,210 @@ mod tests { vec![oversized, "safe history".to_string()], ); - assert!(composer.query.is_empty()); - assert_eq!(composer.cursor, 0); - assert_eq!(composer.history, vec!["safe history".to_string()]); - assert_eq!(composer.validation_status, Some(OVERSIZED_STATUS)); + assert!(composer.composer.contents().is_empty()); + assert_eq!(composer.composer.cursor(), (0, 0)); + assert_eq!( + composer.composer.validation_status(), + Some(OVERSIZED_STATUS) + ); let wrapped = composer.wrapped_text(); assert_eq!(wrapped.rows, vec![String::new()]); composer.handle_event(&key(KeyCode::Char('p'), KeyModifiers::CONTROL)); - assert_eq!(composer.query, "safe history"); - assert_eq!(composer.validation_status, None); + assert_eq!(composer.composer.contents(), "safe history"); + assert_eq!(composer.composer.validation_status(), None); + } + + #[test] + fn first_prompt_is_centered_in_the_available_viewport() { + let editor = editor(100, 30); + let composer = new_composer(&editor, None, 1, String::new(), vec![]); + let outer_width = composer.dialog.width + 2; + let outer_height = composer.dialog.height + 2; + + assert_eq!(composer.dialog.x, (100 - outer_width) / 2); + assert_eq!(composer.dialog.y, (editor.vheight() - outer_height) / 2); + } + + #[test] + fn insert_enter_adds_a_line_and_normal_enter_submits_the_complete_prompt() { + let editor = editor(60, 18); + let mut composer = new_composer(&editor, None, 802, "first".to_string(), vec![]); + + assert_eq!( + composer.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)), + Some(KeyAction::Single(Action::ShowDialog)) + ); + composer.handle_event(&key(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(composer.composer.contents(), "first\nx"); + + assert_eq!( + composer.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)), + Some(KeyAction::Single(Action::ShowDialog)) + ); + assert_eq!(composer.composer.mode(), ModalComposerMode::Normal); + assert_eq!( + composer.handle_event(&key(KeyCode::Enter, KeyModifiers::NONE)), + Some(KeyAction::Multiple(vec![ + Action::CloseDialog, + Action::NotifyPlugin( + "agent".to_string(), + "composer:submitted:802".to_string(), + json!("first\nx") + ) + ])) + ); + } + + #[test] + fn floating_normal_mode_uses_real_buffer_operators_and_undo() { + let editor = editor(60, 18); + let mut composer = new_composer(&editor, None, 802, "one two".to_string(), vec![]); + + composer.handle_event(&key(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_event(&key(KeyCode::Char('0'), KeyModifiers::NONE)); + composer.handle_event(&key(KeyCode::Char('d'), KeyModifiers::NONE)); + composer.handle_event(&key(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(composer.composer.contents(), "two"); + + composer.handle_event(&key(KeyCode::Char('u'), KeyModifiers::NONE)); + assert_eq!(composer.composer.contents(), "one two"); + } + + #[test] + fn narrow_wrapping_uses_ascii_fallback_without_splitting_unicode_graphemes() { + let wrapped = wrap_text("漢👨‍👩‍👧e\u{301}", 1); + + assert_eq!(wrapped.rows, vec!["?", "?", "e\u{301}", ""]); + assert_eq!(wrapped.positions, vec![(0, 0), (1, 0), (2, 0), (3, 0)]); + } + + #[test] + fn floating_placeholder_uses_clean_plain_text_without_quote_prefixes() { + let editor = editor(60, 18); + let composer = new_composer(&editor, None, 802, String::new(), vec![]); + let mut buffer = RenderBuffer::new(60, editor.vheight(), &Style::default()); + + composer.draw(&mut buffer).unwrap(); + let body = rendered_row(&buffer, composer.dialog.y + 1); + + assert!(body.contains("What should the agent do?")); + assert!(!body.contains("> ")); + } + + #[test] + fn configured_ascii_border_preserves_the_centered_popup_title() { + let config = Config { + window_borders_ascii: true, + ..Config::default() + }; + let editor = editor_with_config(60, 18, config); + let composer = new_composer( + &editor, + Some("Ask the agent".to_string()), + 802, + String::new(), + vec![], + ); + let mut buffer = RenderBuffer::new(60, editor.vheight(), &Style::default()); + + composer.draw(&mut buffer).unwrap(); + + let left = composer.dialog.x; + let right = left + composer.dialog.width + 1; + let top = composer.dialog.y; + let bottom = top + composer.dialog.height + 1; + let top_row = rendered_row(&buffer, top); + + assert!(composer.ascii_borders); + assert!(top_row.contains(" Ask the agent ")); + assert_eq!(buffer.cells[top * buffer.width + left].c, '+'); + assert_eq!(buffer.cells[top * buffer.width + right].c, '+'); + assert_eq!(buffer.cells[bottom * buffer.width + left].c, '+'); + assert_eq!(buffer.cells[bottom * buffer.width + right].c, '+'); + assert_eq!(buffer.cells[(top + 1) * buffer.width + left].c, '|'); + assert_eq!(buffer.cells[(top + 1) * buffer.width + right].c, '|'); + assert!(!top_row.contains('─')); + assert!(!top_row.contains('┌')); + } + + #[test] + fn default_border_preserves_unicode_corners_and_clips_long_title_inside_them() { + let editor = editor(8, 8); + let composer = new_composer( + &editor, + Some("A very long 👨‍👩‍👧 title".to_string()), + 802, + String::new(), + vec![], + ); + let mut buffer = RenderBuffer::new(8, editor.vheight(), &Style::default()); + + composer.draw(&mut buffer).unwrap(); + + let left = composer.dialog.x; + let right = left + composer.dialog.width + 1; + let top = composer.dialog.y; + let bottom = top + composer.dialog.height + 1; + + assert!(!composer.ascii_borders); + assert_eq!(buffer.cells[top * buffer.width + left].c, '┌'); + assert_eq!(buffer.cells[top * buffer.width + right].c, '┐'); + assert_eq!(buffer.cells[bottom * buffer.width + left].c, '└'); + assert_eq!(buffer.cells[bottom * buffer.width + right].c, '┘'); + } + + #[test] + fn resizing_to_a_zero_viewport_hides_the_cursor_and_draws_safely() { + let editor = editor(60, 18); + let mut composer = new_composer( + &editor, + Some("Ask the agent".to_string()), + 802, + "👨‍👩‍👧漢".to_string(), + vec![], + ); + + assert!(composer.resize(0, 0)); + let mut buffer = RenderBuffer::new(0, 0, &Style::default()); + + composer.draw(&mut buffer).unwrap(); + assert_eq!(composer.cursor_position(), None); + } + + #[test] + fn ascii_border_keeps_all_corners_on_screen_after_tiny_resize() { + let config = Config { + window_borders_ascii: true, + ..Config::default() + }; + let editor = editor_with_config(60, 18, config); + let mut composer = new_composer( + &editor, + Some("A long title".to_string()), + 802, + "👨‍👩‍👧漢".to_string(), + vec![], + ); + + for (width, height) in [(8, 4), (2, 2), (1, 1)] { + assert!(composer.resize(width, height)); + let mut buffer = RenderBuffer::new(width, height, &Style::default()); + + composer.draw(&mut buffer).unwrap(); + + let (cursor_x, cursor_y) = composer.cursor_position().unwrap(); + assert!(cursor_x < width); + assert!(cursor_y < height); + if width >= 2 && height >= 2 { + let left = composer.dialog.x; + let right = left + composer.dialog.width + 1; + let top = composer.dialog.y; + let bottom = top + composer.dialog.height + 1; + assert_eq!(buffer.cells[top * width + left].c, '+'); + assert_eq!(buffer.cells[top * width + right].c, '+'); + assert_eq!(buffer.cells[bottom * width + left].c, '+'); + assert_eq!(buffer.cells[bottom * width + right].c, '+'); + } + } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1f5702b3..5bd7c44f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,10 +16,11 @@ mod info; mod input_prompt; mod keymap_hints; mod list; +mod modal_composer; mod picker; +pub(crate) use agent_composer::wrap_text; pub use agent_composer::AgentComposer; -pub(crate) use agent_composer::{normalize_newlines, wrap_text}; pub use completion::CompletionUI; pub use confirmation::Confirmation; use crossterm::event::{Event, KeyCode, MouseEvent, MouseEventKind}; @@ -30,6 +31,7 @@ pub use info::Info; pub use input_prompt::InputPrompt; pub(crate) use keymap_hints::draw_keymap_hints; use list::List; +pub(crate) use modal_composer::{ModalComposer, ModalComposerMode, ModalComposerOutcome}; pub(crate) use picker::{picker_file_icon, picker_file_icon_color}; pub use picker::{ LegacyPickerOptions, Picker, PickerIcon, PickerItem, PickerOptions, PickerPresentation, @@ -38,7 +40,7 @@ pub use picker::{ use crate::{ config::KeyAction, - editor::{Action, RenderBuffer}, + editor::{Action, Mode, RenderBuffer}, plugin::{ComposerHandle, PickerHandle}, theme::Theme, }; @@ -97,6 +99,11 @@ pub trait Component: Send { false } + /// Returns the editing mode that should determine the terminal cursor. + fn cursor_mode(&self) -> Option { + None + } + fn cursor_position(&self) -> Option<(usize, usize)> { None } diff --git a/src/ui/modal_composer.rs b/src/ui/modal_composer.rs new file mode 100644 index 00000000..d8f150b6 --- /dev/null +++ b/src/ui/modal_composer.rs @@ -0,0 +1,1304 @@ +//! Buffer-backed Vim-style editing shared by floating and docked agent composers. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::{ + buffer::Buffer, + undo::{CursorSnapshot, TextPosition, TextRange}, + unicode_utils::{char_to_byte, char_to_grapheme, grapheme_to_char, trim_line_ending}, +}; + +/// Largest prompt that remains safely below the app-server's JSON frame limit. +pub(crate) const MAX_PROMPT_BYTES: usize = 128 * 1024; +const MAX_PROMPT_HISTORY: usize = 50; + +const OVERSIZED_STATUS: &str = "Prompt exceeds 128 KiB"; +const EMPTY_STATUS: &str = "Prompt is empty"; + +/// Vim mode of an agent prompt's independent, in-memory editor buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum ModalComposerMode { + /// Navigate and operate on the draft without inserting typed commands. + Normal, + /// Insert characters and multiline text into the draft. + #[default] + Insert, + /// Select text using the same motions as normal mode. + Visual, +} + +impl ModalComposerMode { + /// Returns a compact, user-facing Vim-mode label. + #[must_use] + pub(crate) fn label(self) -> &'static str { + match self { + Self::Normal => "NORMAL", + Self::Insert => "INSERT", + Self::Visual => "VISUAL", + } + } +} + +/// Result of handling one key or bracketed paste in the shared prompt editor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ModalComposerOutcome { + /// Text, cursor position, mode, history, or validation state changed. + Changed, + /// The nonempty prompt should be submitted by the owning surface. + Submit, + /// The owning surface should handle this key, such as `Ctrl-C`. + Unhandled, + /// The operation was rejected without changing the existing draft. + Rejected, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingOperator { + Delete, + Change, + Yank, +} + +/// An independent real editor buffer with Vim motions, operators, and undo history. +/// +/// The synthetic file name is never registered with the editor and never touches disk; +/// it keeps `Buffer::new` from manufacturing a newline for an empty unnamed buffer. +#[derive(Debug)] +pub(crate) struct ModalComposer { + buffer: Buffer, + cursor: TextPosition, + mode: ModalComposerMode, + visual_anchor: Option, + preferred_column: Option, + pending_operator: Option, + pending_text_object: bool, + pending_g: bool, + register: String, + history: Vec, + history_position: Option, + history_draft: Option, + validation_status: Option<&'static str>, +} + +impl ModalComposer { + /// Creates an insert-mode scratch buffer, retaining only safe history entries. + #[must_use] + pub(crate) fn new(text: &str, history: Vec) -> Self { + let normalized = normalize_newlines(text); + let initial_too_large = normalized.len() > MAX_PROMPT_BYTES; + let contents = if initial_too_large { "" } else { &normalized }; + let original_history_len = history.len(); + let history = history + .into_iter() + .filter_map(|entry| { + let entry = normalize_newlines(&entry); + (entry.len() <= MAX_PROMPT_BYTES).then_some(entry) + }) + .collect::>(); + let history_too_large = history.len() != original_history_len; + let buffer = Self::scratch_buffer(contents); + let cursor = buffer.char_idx_to_position(contents.chars().count()); + + Self { + buffer, + cursor, + mode: ModalComposerMode::Insert, + visual_anchor: None, + preferred_column: None, + pending_operator: None, + pending_text_object: false, + pending_g: false, + register: String::new(), + history, + history_position: None, + history_draft: None, + validation_status: (initial_too_large || history_too_large).then_some(OVERSIZED_STATUS), + } + } + + fn scratch_buffer(contents: &str) -> Buffer { + Buffer::new( + Some("red-buffer://agent-composer".to_string()), + contents.to_string(), + ) + } + + /// Returns the complete authoritative draft from the real editor buffer. + #[must_use] + pub(crate) fn contents(&self) -> String { + self.buffer.contents() + } + + /// Returns the cursor's grapheme column and zero-based logical line. + #[must_use] + pub(crate) fn cursor(&self) -> (usize, usize) { + let line = self.line_text(self.cursor.line); + ( + char_to_grapheme(&line, self.cursor.character), + self.cursor.line, + ) + } + + /// Returns the cursor's grapheme offset in the complete multiline draft. + #[must_use] + pub(crate) fn cursor_grapheme_index(&self) -> usize { + let contents = self.buffer.contents(); + let character = self.buffer.position_to_char_idx(self.cursor); + let byte = char_to_byte(&contents, character); + crate::unicode_utils::grapheme_len(&contents[..byte]) + } + + /// Places the cursor at a complete grapheme in the multiline prompt. + pub(crate) fn set_cursor_grapheme_index(&mut self, index: usize) { + let contents = self.contents(); + let index = index.min(crate::unicode_utils::grapheme_len(&contents)); + let character = grapheme_to_char(&contents, index); + self.cursor = self.buffer.char_idx_to_position(character); + self.preferred_column = None; + self.clamp_cursor(); + } + + /// Returns the currently active Vim mode. + #[must_use] + pub(crate) fn mode(&self) -> ModalComposerMode { + self.mode + } + + /// Returns a validation message for an empty or oversized operation. + #[must_use] + pub(crate) fn validation_status(&self) -> Option<&'static str> { + self.validation_status + } + + /// Returns the current inclusive visual selection as a half-open buffer range. + #[must_use] + pub(crate) fn selection_range(&self) -> Option { + let anchor = self.visual_anchor?; + let (start, last) = if Self::position_key(anchor) <= Self::position_key(self.cursor) { + (anchor, self.cursor) + } else { + (self.cursor, anchor) + }; + Some(TextRange::new(start, self.next_position(last))) + } + + /// Replaces the draft without changing the caller's current Vim mode. + /// + /// Returns `false` and preserves the old draft when `text` is oversized. + pub(crate) fn set_contents(&mut self, text: &str) -> bool { + let normalized = normalize_newlines(text); + if normalized.len() > MAX_PROMPT_BYTES { + self.validation_status = Some(OVERSIZED_STATUS); + return false; + } + self.buffer = Self::scratch_buffer(&normalized); + self.cursor = self.buffer.char_idx_to_position(normalized.chars().count()); + self.visual_anchor = None; + self.preferred_column = None; + self.pending_operator = None; + self.pending_text_object = false; + self.pending_g = false; + self.validation_status = None; + self.clamp_cursor(); + true + } + + /// Inserts a complete bracketed paste as one bounded, undoable edit. + pub(crate) fn handle_paste(&mut self, text: &str) -> ModalComposerOutcome { + self.insert_text(text) + } + + /// Handles editor-native Vim navigation and mode-aware prompt submission. + pub(crate) fn handle_key(&mut self, key: KeyEvent) -> ModalComposerOutcome { + if matches!(key.code, KeyCode::Enter | KeyCode::Char('\n' | '\r')) + && key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + return self.submit(); + } + + if key.modifiers.contains(KeyModifiers::CONTROL) { + return self.handle_control_key(key.code); + } + + match self.mode { + ModalComposerMode::Insert => self.handle_insert_key(key), + ModalComposerMode::Normal => self.handle_normal_key(key), + ModalComposerMode::Visual => self.handle_visual_key(key), + } + } + + /// Takes a validated prompt, remembers it, and starts a fresh insert-mode draft. + pub(crate) fn take_submission(&mut self) -> Option { + if self.submit() != ModalComposerOutcome::Submit { + return None; + } + + let text = self.contents(); + self.history.retain(|entry| entry != &text); + self.history.insert(0, text.clone()); + self.history.truncate(MAX_PROMPT_HISTORY); + self.history_position = None; + self.history_draft = None; + self.mode = ModalComposerMode::Insert; + self.set_contents(""); + Some(text) + } + + /// Recalls the next older prompt while retaining the unsubmitted draft. + pub(crate) fn history_back(&mut self) -> ModalComposerOutcome { + if self.history.is_empty() { + return ModalComposerOutcome::Changed; + } + let position = match self.history_position { + Some(position) => (position + 1).min(self.history.len() - 1), + None => { + self.history_draft = Some(self.contents()); + 0 + } + }; + self.history_position = Some(position); + let entry = self.history[position].clone(); + self.set_contents(&entry); + self.history_position = Some(position); + ModalComposerOutcome::Changed + } + + /// Recalls a newer prompt, eventually restoring the original draft. + pub(crate) fn history_forward(&mut self) -> ModalComposerOutcome { + let Some(position) = self.history_position else { + return ModalComposerOutcome::Changed; + }; + if position == 0 { + let draft = self.history_draft.take().unwrap_or_default(); + self.set_contents(&draft); + self.history_position = None; + } else { + let next = position - 1; + let entry = self.history[next].clone(); + self.set_contents(&entry); + self.history_position = Some(next); + } + ModalComposerOutcome::Changed + } + + fn handle_control_key(&mut self, code: KeyCode) -> ModalComposerOutcome { + match code { + KeyCode::Char('p' | 'P') => self.history_back(), + KeyCode::Char('n' | 'N') => self.history_forward(), + KeyCode::Char('j' | 'J') => self.insert_text("\n"), + KeyCode::Char('w' | 'W') if self.mode == ModalComposerMode::Insert => { + self.delete_previous_word() + } + KeyCode::Char('r' | 'R') if self.mode == ModalComposerMode::Normal => self.redo(), + _ => ModalComposerOutcome::Unhandled, + } + } + + fn handle_insert_key(&mut self, key: KeyEvent) -> ModalComposerOutcome { + match key.code { + KeyCode::Esc => { + self.finish_transaction(); + self.mode = ModalComposerMode::Normal; + self.clamp_cursor(); + ModalComposerOutcome::Changed + } + KeyCode::Enter => self.insert_text("\n"), + KeyCode::Backspace => self.backspace(), + KeyCode::Delete => self.delete_forward(), + KeyCode::Left => self.move_horizontal(-1), + KeyCode::Right => self.move_horizontal(1), + KeyCode::Up => self.move_vertical(-1), + KeyCode::Down => self.move_vertical(1), + KeyCode::Home => self.move_line_start(), + KeyCode::End => self.move_line_end(), + KeyCode::Tab => self.insert_text("\t"), + KeyCode::Char(character) if !key.modifiers.contains(KeyModifiers::ALT) => { + self.insert_text(&character.to_string()) + } + _ => ModalComposerOutcome::Unhandled, + } + } + + fn handle_normal_key(&mut self, key: KeyEvent) -> ModalComposerOutcome { + if self.pending_operator.is_some() { + return self.handle_operator_key(key); + } + match key.code { + KeyCode::Enter => self.submit(), + KeyCode::Esc => { + self.pending_g = false; + ModalComposerOutcome::Changed + } + KeyCode::Left | KeyCode::Char('h') => self.move_horizontal(-1), + KeyCode::Right | KeyCode::Char('l') => self.move_horizontal(1), + KeyCode::Up | KeyCode::Char('k') => self.move_vertical(-1), + KeyCode::Down | KeyCode::Char('j') => self.move_vertical(1), + KeyCode::Home | KeyCode::Char('0') => self.move_line_start(), + KeyCode::End | KeyCode::Char('$') => self.move_line_end(), + KeyCode::Char('^') => self.move_first_non_whitespace(), + KeyCode::Char('w') => self.move_word_forward(), + KeyCode::Char('b') => self.move_word_backward(), + KeyCode::Char('e') => self.move_word_end(), + KeyCode::Char('i') => self.enter_insert(), + KeyCode::Char('a') => { + self.move_horizontal(1); + self.enter_insert() + } + KeyCode::Char('I') => { + self.move_first_non_whitespace(); + self.enter_insert() + } + KeyCode::Char('A') => { + self.move_line_end(); + self.enter_insert() + } + KeyCode::Char('o') => { + self.move_line_end(); + self.mode = ModalComposerMode::Insert; + self.insert_text("\n") + } + KeyCode::Char('O') => { + let line = self.cursor.line; + self.cursor = TextPosition::new(line, 0); + self.mode = ModalComposerMode::Insert; + let outcome = self.insert_text("\n"); + self.cursor = TextPosition::new(line, 0); + outcome + } + KeyCode::Char('v') => { + self.visual_anchor = Some(self.cursor); + self.mode = ModalComposerMode::Visual; + ModalComposerOutcome::Changed + } + KeyCode::Char('d') => self.begin_operator(PendingOperator::Delete), + KeyCode::Char('c') => self.begin_operator(PendingOperator::Change), + KeyCode::Char('y') => self.begin_operator(PendingOperator::Yank), + KeyCode::Char('x') | KeyCode::Delete => self.delete_forward(), + KeyCode::Char('p') => self.paste_register(), + KeyCode::Char('u') => self.undo(), + KeyCode::Char('g') => { + if self.pending_g { + self.pending_g = false; + self.cursor = TextPosition::new(0, 0); + } else { + self.pending_g = true; + } + ModalComposerOutcome::Changed + } + KeyCode::Char('G') => { + self.pending_g = false; + self.cursor = TextPosition::new(self.buffer.len(), 0); + self.clamp_cursor(); + ModalComposerOutcome::Changed + } + _ => { + self.pending_g = false; + ModalComposerOutcome::Unhandled + } + } + } + + fn handle_visual_key(&mut self, key: KeyEvent) -> ModalComposerOutcome { + match key.code { + KeyCode::Esc | KeyCode::Char('v') => { + self.visual_anchor = None; + self.mode = ModalComposerMode::Normal; + self.clamp_cursor(); + ModalComposerOutcome::Changed + } + KeyCode::Char('d' | 'x') => self.apply_visual_operator(PendingOperator::Delete), + KeyCode::Char('c') => self.apply_visual_operator(PendingOperator::Change), + KeyCode::Char('y') => self.apply_visual_operator(PendingOperator::Yank), + KeyCode::Left | KeyCode::Char('h') => self.move_horizontal(-1), + KeyCode::Right | KeyCode::Char('l') => self.move_horizontal(1), + KeyCode::Up | KeyCode::Char('k') => self.move_vertical(-1), + KeyCode::Down | KeyCode::Char('j') => self.move_vertical(1), + KeyCode::Home | KeyCode::Char('0') => self.move_line_start(), + KeyCode::End | KeyCode::Char('$') => self.move_line_end(), + KeyCode::Char('w') => self.move_word_forward(), + KeyCode::Char('b') => self.move_word_backward(), + KeyCode::Char('e') => self.move_word_end(), + _ => ModalComposerOutcome::Unhandled, + } + } + + fn submit(&mut self) -> ModalComposerOutcome { + if self.contents().trim().is_empty() { + self.validation_status = Some(EMPTY_STATUS); + return ModalComposerOutcome::Rejected; + } + self.finish_transaction(); + self.validation_status = None; + ModalComposerOutcome::Submit + } + + fn enter_insert(&mut self) -> ModalComposerOutcome { + self.mode = ModalComposerMode::Insert; + self.preferred_column = None; + ModalComposerOutcome::Changed + } + + fn insert_text(&mut self, text: &str) -> ModalComposerOutcome { + let normalized = normalize_newlines(text); + if normalized.is_empty() { + return ModalComposerOutcome::Changed; + } + if normalized.len() > MAX_PROMPT_BYTES.saturating_sub(self.buffer.byte_len()) { + self.validation_status = Some(OVERSIZED_STATUS); + return ModalComposerOutcome::Rejected; + } + let range = TextRange::insertion(self.cursor); + self.replace_range(range, &normalized, "insert prompt text"); + ModalComposerOutcome::Changed + } + + fn backspace(&mut self) -> ModalComposerOutcome { + let end = self.cursor; + let Some(start) = self.previous_position(end) else { + return ModalComposerOutcome::Changed; + }; + self.replace_range(TextRange::new(start, end), "", "delete prompt text"); + ModalComposerOutcome::Changed + } + + fn delete_forward(&mut self) -> ModalComposerOutcome { + let start = self.cursor; + let end = self.next_position(start); + if start == end { + return ModalComposerOutcome::Changed; + } + self.register = self.buffer.text_in_range(TextRange::new(start, end)); + self.replace_range(TextRange::new(start, end), "", "delete prompt text"); + ModalComposerOutcome::Changed + } + + fn delete_previous_word(&mut self) -> ModalComposerOutcome { + let end = self.cursor; + let Some((character, line)) = self + .buffer + .find_prev_word((self.cursor.character, self.cursor.line)) + else { + if self.cursor.character == 0 && self.cursor.line == 0 { + return ModalComposerOutcome::Changed; + } + self.replace_range( + TextRange::new(TextPosition::new(0, 0), end), + "", + "delete previous prompt word", + ); + return ModalComposerOutcome::Changed; + }; + self.replace_range( + TextRange::new(TextPosition::new(line, character), end), + "", + "delete previous prompt word", + ); + ModalComposerOutcome::Changed + } + + fn replace_range(&mut self, range: TextRange, replacement: &str, label: &str) { + let old_text = self.buffer.text_in_range(range); + if old_text == replacement { + return; + } + let before = self.cursor_snapshot(); + self.buffer.undo_history.begin_transaction(label, before); + let start_char = self.buffer.position_to_char_idx(range.start); + self.buffer.undo_history.record_replace( + range, + start_char, + old_text, + replacement.to_string(), + ); + self.buffer.replace_range_raw(range, replacement); + self.cursor = self.buffer.range_for_text(range.start, replacement).end; + self.preferred_column = None; + self.validation_status = None; + self.history_position = None; + self.history_draft = None; + if self.mode != ModalComposerMode::Insert { + self.finish_transaction(); + self.clamp_cursor(); + } + } + + fn finish_transaction(&mut self) { + let after = self.cursor_snapshot(); + self.buffer.undo_history.commit_transaction(after); + self.buffer.refresh_dirty_from_history(); + } + + fn undo(&mut self) -> ModalComposerOutcome { + self.finish_transaction(); + let mut history = std::mem::take(&mut self.buffer.undo_history); + let cursor = history.undo(&mut self.buffer).map(|(cursor, _)| cursor); + self.buffer.undo_history = history; + if let Some(cursor) = cursor { + let line = self.line_text(cursor.y); + self.cursor = TextPosition::new(cursor.y, grapheme_to_char(&line, cursor.x)); + self.buffer.refresh_dirty_from_history(); + self.clamp_cursor(); + } + ModalComposerOutcome::Changed + } + + fn redo(&mut self) -> ModalComposerOutcome { + self.finish_transaction(); + let mut history = std::mem::take(&mut self.buffer.undo_history); + let cursor = history.redo(&mut self.buffer).map(|(cursor, _)| cursor); + self.buffer.undo_history = history; + if let Some(cursor) = cursor { + let line = self.line_text(cursor.y); + self.cursor = TextPosition::new(cursor.y, grapheme_to_char(&line, cursor.x)); + self.buffer.refresh_dirty_from_history(); + self.clamp_cursor(); + } + ModalComposerOutcome::Changed + } + + fn cursor_snapshot(&self) -> CursorSnapshot { + let (x, y) = self.cursor(); + CursorSnapshot::new(x, y, 0) + } + + fn line_text(&self, line: usize) -> String { + self.buffer + .get(line) + .map(|text| trim_line_ending(&text).to_string()) + .unwrap_or_default() + } + + fn move_horizontal(&mut self, delta: isize) -> ModalComposerOutcome { + let line = self.line_text(self.cursor.line); + let current = char_to_grapheme(&line, self.cursor.character); + let last = crate::unicode_utils::grapheme_len(&line); + let target = current.saturating_add_signed(delta).min(last); + self.cursor.character = grapheme_to_char(&line, target); + self.preferred_column = None; + ModalComposerOutcome::Changed + } + + fn move_vertical(&mut self, delta: isize) -> ModalComposerOutcome { + let goal = self.preferred_column.unwrap_or_else(|| self.cursor().0); + let target_line = self + .cursor + .line + .saturating_add_signed(delta) + .min(self.buffer.len()); + let line = self.line_text(target_line); + self.cursor = TextPosition::new(target_line, grapheme_to_char(&line, goal)); + self.preferred_column = Some(goal); + self.clamp_cursor(); + ModalComposerOutcome::Changed + } + + fn move_line_start(&mut self) -> ModalComposerOutcome { + self.cursor.character = 0; + self.preferred_column = None; + ModalComposerOutcome::Changed + } + + fn move_line_end(&mut self) -> ModalComposerOutcome { + self.cursor.character = self.line_text(self.cursor.line).chars().count(); + self.preferred_column = None; + ModalComposerOutcome::Changed + } + + fn move_first_non_whitespace(&mut self) -> ModalComposerOutcome { + let line = self.line_text(self.cursor.line); + self.cursor.character = line + .chars() + .position(|character| !character.is_whitespace()) + .unwrap_or(0); + self.preferred_column = None; + ModalComposerOutcome::Changed + } + + fn move_word_forward(&mut self) -> ModalComposerOutcome { + if let Some((character, line)) = self + .buffer + .find_next_word((self.cursor.character, self.cursor.line)) + { + self.cursor = TextPosition::new(line, character); + self.clamp_cursor(); + } + self.preferred_column = None; + ModalComposerOutcome::Changed + } + + fn move_word_backward(&mut self) -> ModalComposerOutcome { + if let Some((character, line)) = self + .buffer + .find_prev_word((self.cursor.character, self.cursor.line)) + { + self.cursor = TextPosition::new(line, character); + } + self.preferred_column = None; + ModalComposerOutcome::Changed + } + + fn move_word_end(&mut self) -> ModalComposerOutcome { + if let Some((character, line)) = self + .buffer + .find_word_end((self.cursor.character, self.cursor.line)) + { + self.cursor = TextPosition::new(line, character.saturating_sub(1)); + self.clamp_cursor(); + } + self.preferred_column = None; + ModalComposerOutcome::Changed + } + + fn clamp_cursor(&mut self) { + self.cursor.line = self.cursor.line.min(self.buffer.len()); + let line = self.line_text(self.cursor.line); + let line_end = line.chars().count(); + let maximum = if self.mode == ModalComposerMode::Insert || line_end == 0 { + line_end + } else { + let graphemes = crate::unicode_utils::grapheme_len(&line); + grapheme_to_char(&line, graphemes.saturating_sub(1)) + }; + self.cursor.character = self.cursor.character.min(maximum); + } + + fn previous_position(&self, position: TextPosition) -> Option { + if position.character > 0 { + let line = self.line_text(position.line); + let grapheme = char_to_grapheme(&line, position.character); + return Some(TextPosition::new( + position.line, + grapheme_to_char(&line, grapheme.saturating_sub(1)), + )); + } + if position.line == 0 { + return None; + } + let line = self.line_text(position.line - 1); + Some(TextPosition::new(position.line - 1, line.chars().count())) + } + + fn next_position(&self, position: TextPosition) -> TextPosition { + let line = self.line_text(position.line); + let grapheme = char_to_grapheme(&line, position.character); + let grapheme_count = crate::unicode_utils::grapheme_len(&line); + if grapheme < grapheme_count { + return TextPosition::new(position.line, grapheme_to_char(&line, grapheme + 1)); + } + if position.line < self.buffer.len() { + TextPosition::new(position.line + 1, 0) + } else { + TextPosition::new(position.line, line.chars().count()) + } + } + + fn begin_operator(&mut self, operator: PendingOperator) -> ModalComposerOutcome { + self.pending_g = false; + self.pending_text_object = false; + self.pending_operator = Some(operator); + ModalComposerOutcome::Changed + } + + fn handle_operator_key(&mut self, key: KeyEvent) -> ModalComposerOutcome { + let Some(operator) = self.pending_operator else { + return ModalComposerOutcome::Unhandled; + }; + if key.code == KeyCode::Esc { + self.pending_operator = None; + self.pending_text_object = false; + return ModalComposerOutcome::Changed; + } + if matches!(key.code, KeyCode::Char('i' | 'a')) { + self.pending_text_object = true; + return ModalComposerOutcome::Changed; + } + let origin = self.cursor; + if self.pending_text_object && key.code == KeyCode::Char('w') { + self.pending_operator = None; + self.pending_text_object = false; + return self.apply_word_object(operator); + } + + let repeated = matches!( + (operator, key.code), + (PendingOperator::Delete, KeyCode::Char('d')) + | (PendingOperator::Change, KeyCode::Char('c')) + | (PendingOperator::Yank, KeyCode::Char('y')) + ); + if repeated { + self.pending_operator = None; + self.pending_text_object = false; + let line_start = TextPosition::new(origin.line, 0); + let line_end = if origin.line < self.buffer.len() { + TextPosition::new(origin.line + 1, 0) + } else { + TextPosition::new(origin.line, self.line_text(origin.line).chars().count()) + }; + return self.apply_operator(operator, TextRange::new(line_start, line_end)); + } + + let destination = match key.code { + KeyCode::Char('w') => self + .buffer + .find_next_word((origin.character, origin.line)) + .map(|(character, line)| TextPosition::new(line, character)), + KeyCode::Char('b') => self + .buffer + .find_prev_word((origin.character, origin.line)) + .map(|(character, line)| TextPosition::new(line, character)), + KeyCode::Char('e') => self + .buffer + .find_word_end((origin.character, origin.line)) + .map(|(character, line)| TextPosition::new(line, character)), + KeyCode::Char('$') => Some(TextPosition::new( + origin.line, + self.line_text(origin.line).chars().count(), + )), + KeyCode::Char('0') => Some(TextPosition::new(origin.line, 0)), + KeyCode::Char('j') if origin.line < self.buffer.len() => { + Some(TextPosition::new(origin.line + 1, 0)) + } + KeyCode::Char('k') if origin.line > 0 => Some(TextPosition::new(origin.line - 1, 0)), + _ => None, + }; + self.pending_operator = None; + self.pending_text_object = false; + let Some(destination) = destination else { + return ModalComposerOutcome::Changed; + }; + let (start, end) = if Self::position_key(origin) <= Self::position_key(destination) { + (origin, destination) + } else { + (destination, origin) + }; + self.apply_operator(operator, TextRange::new(start, end)) + } + + fn apply_word_object(&mut self, operator: PendingOperator) -> ModalComposerOutcome { + let line = self.line_text(self.cursor.line); + let graphemes = line.graphemes(true).collect::>(); + if graphemes.is_empty() { + return ModalComposerOutcome::Changed; + } + let index = char_to_grapheme(&line, self.cursor.character).min(graphemes.len() - 1); + let is_keyword = |grapheme: &str| { + grapheme + .chars() + .any(|character| character.is_alphanumeric() || character == '_') + }; + let class = is_keyword(graphemes[index]); + let mut start = index; + while start > 0 && is_keyword(graphemes[start - 1]) == class { + start -= 1; + } + let mut end = index + 1; + while end < graphemes.len() && is_keyword(graphemes[end]) == class { + end += 1; + } + self.apply_operator( + operator, + TextRange::new( + TextPosition::new(self.cursor.line, grapheme_to_char(&line, start)), + TextPosition::new(self.cursor.line, grapheme_to_char(&line, end)), + ), + ) + } + + fn apply_visual_operator(&mut self, operator: PendingOperator) -> ModalComposerOutcome { + let Some(range) = self.selection_range() else { + return ModalComposerOutcome::Changed; + }; + self.visual_anchor = None; + self.mode = ModalComposerMode::Normal; + self.apply_operator(operator, range) + } + + fn apply_operator( + &mut self, + operator: PendingOperator, + range: TextRange, + ) -> ModalComposerOutcome { + if range.start == range.end { + if operator == PendingOperator::Change { + self.mode = ModalComposerMode::Insert; + } + return ModalComposerOutcome::Changed; + } + self.register = self.buffer.text_in_range(range); + if operator == PendingOperator::Yank { + self.cursor = range.start; + self.clamp_cursor(); + return ModalComposerOutcome::Changed; + } + if operator == PendingOperator::Change { + self.mode = ModalComposerMode::Insert; + } + self.replace_range(range, "", "operate on prompt text"); + if operator != PendingOperator::Change { + self.clamp_cursor(); + } + ModalComposerOutcome::Changed + } + + fn paste_register(&mut self) -> ModalComposerOutcome { + if self.register.is_empty() { + return ModalComposerOutcome::Changed; + } + let text = self.register.clone(); + self.cursor = self.next_position(self.cursor); + self.insert_text(&text) + } + + fn position_key(position: TextPosition) -> (usize, usize) { + (position.line, position.character) + } +} + +/// Normalizes Windows and classic Mac newline sequences for prompt buffers. +#[must_use] +pub(crate) fn normalize_newlines(text: &str) -> String { + text.replace("\r\n", "\n").replace('\r', "\n") +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + use super::{ModalComposer, ModalComposerMode, ModalComposerOutcome, MAX_PROMPT_BYTES}; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn control(character: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL) + } + + fn modified_enter(modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(KeyCode::Enter, modifiers) + } + + fn modified_enter_codes() -> [KeyCode; 3] { + [KeyCode::Enter, KeyCode::Char('\n'), KeyCode::Char('\r')] + } + + fn normal(composer: &mut ModalComposer) { + assert_eq!( + composer.handle_key(key(KeyCode::Esc)), + ModalComposerOutcome::Changed + ); + assert_eq!(composer.mode(), ModalComposerMode::Normal); + } + + #[test] + fn empty_scratch_buffer_has_no_manufactured_newline() { + let composer = ModalComposer::new("", vec![]); + assert_eq!(composer.contents(), ""); + assert_eq!(composer.cursor(), (0, 0)); + assert_eq!(composer.mode(), ModalComposerMode::Insert); + } + + #[test] + fn insert_enter_shift_enter_and_control_j_create_newlines() { + let mut composer = ModalComposer::new("hello", vec![]); + assert_eq!( + composer.handle_key(key(KeyCode::Enter)), + ModalComposerOutcome::Changed + ); + assert_eq!(composer.contents(), "hello\n"); + + assert_eq!( + composer.handle_key(modified_enter(KeyModifiers::SHIFT)), + ModalComposerOutcome::Changed + ); + assert_eq!(composer.contents(), "hello\n\n"); + + assert_eq!( + composer.handle_key(control('j')), + ModalComposerOutcome::Changed + ); + assert_eq!(composer.contents(), "hello\n\n\n"); + + normal(&mut composer); + assert_eq!(composer.contents(), "hello\n\n\n"); + } + + #[test] + fn control_enter_submits_immediately_from_insert_without_adding_a_newline() { + for code in modified_enter_codes() { + let mut composer = ModalComposer::new("first\n漢👨‍👩‍👧", vec![]); + + assert_eq!(composer.mode(), ModalComposerMode::Insert); + assert_eq!( + composer.handle_key(KeyEvent::new(code, KeyModifiers::CONTROL)), + ModalComposerOutcome::Submit, + "Ctrl+Enter should immediately submit {code:?} in Insert mode", + ); + assert_eq!(composer.mode(), ModalComposerMode::Insert); + assert_eq!(composer.contents(), "first\n漢👨‍👩‍👧"); + assert_eq!(composer.validation_status(), None); + } + } + + #[test] + fn control_and_alt_enter_submit_in_every_vim_mode() { + let modifiers = [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + KeyModifiers::ALT | KeyModifiers::SHIFT, + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, + ]; + + for code in modified_enter_codes() { + for modifiers in modifiers { + for mode in [ + ModalComposerMode::Insert, + ModalComposerMode::Normal, + ModalComposerMode::Visual, + ] { + let mut composer = ModalComposer::new("hello\n漢👨‍👩‍👧", vec![]); + + if mode != ModalComposerMode::Insert { + normal(&mut composer); + if mode == ModalComposerMode::Visual { + assert_eq!( + composer.handle_key(key(KeyCode::Char('v'))), + ModalComposerOutcome::Changed + ); + } + } + + assert_eq!(composer.mode(), mode); + assert_eq!( + composer.handle_key(KeyEvent::new(code, modifiers)), + ModalComposerOutcome::Submit, + "{code:?} should submit in {mode:?} with {modifiers:?}", + ); + assert_eq!(composer.contents(), "hello\n漢👨‍👩‍👧"); + } + } + } + } + + #[test] + fn modified_enter_takes_precedence_over_a_pending_normal_operator() { + for code in modified_enter_codes() { + let mut composer = ModalComposer::new("keep this draft", vec![]); + normal(&mut composer); + + assert_eq!( + composer.handle_key(key(KeyCode::Char('d'))), + ModalComposerOutcome::Changed + ); + assert_eq!( + composer.handle_key(KeyEvent::new(code, KeyModifiers::CONTROL)), + ModalComposerOutcome::Submit, + "{code:?} should take precedence over a pending operator", + ); + assert_eq!(composer.contents(), "keep this draft"); + } + } + + #[test] + fn unmodified_normal_enter_submits_without_losing_draft() { + let mut composer = ModalComposer::new("hello", vec![]); + normal(&mut composer); + + assert_eq!( + composer.handle_key(key(KeyCode::Enter)), + ModalComposerOutcome::Submit + ); + assert_eq!(composer.contents(), "hello"); + } + + #[test] + fn control_s_is_not_an_agent_submission_shortcut() { + for mode in [ + ModalComposerMode::Insert, + ModalComposerMode::Normal, + ModalComposerMode::Visual, + ] { + let mut composer = ModalComposer::new("keep this draft", vec![]); + + if mode != ModalComposerMode::Insert { + normal(&mut composer); + if mode == ModalComposerMode::Visual { + composer.handle_key(key(KeyCode::Char('v'))); + } + } + + assert_eq!(composer.mode(), mode); + assert_eq!( + composer.handle_key(control('s')), + ModalComposerOutcome::Unhandled, + "Ctrl+S must not submit in {mode:?}", + ); + assert_eq!(composer.contents(), "keep this draft"); + } + } + + #[test] + fn modified_enter_rejects_empty_submissions_without_losing_draft() { + for code in modified_enter_codes() { + for modifiers in [ + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ] { + let mut composer = ModalComposer::new(" \n", vec![]); + + assert_eq!( + composer.handle_key(KeyEvent::new(code, modifiers)), + ModalComposerOutcome::Rejected, + "empty drafts must not submit {code:?} with {modifiers:?}", + ); + assert_eq!(composer.validation_status(), Some("Prompt is empty")); + assert_eq!(composer.contents(), " \n"); + } + } + } + + #[test] + fn normal_mode_supports_words_and_line_navigation() { + let mut composer = ModalComposer::new("one two\nthree four", vec![]); + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('g'))); + composer.handle_key(key(KeyCode::Char('g'))); + assert_eq!(composer.cursor(), (0, 0)); + composer.handle_key(key(KeyCode::Char('w'))); + assert_eq!(composer.cursor(), (4, 0)); + composer.handle_key(key(KeyCode::Char('j'))); + assert_eq!(composer.cursor(), (4, 1)); + composer.handle_key(key(KeyCode::Char('0'))); + assert_eq!(composer.cursor(), (0, 1)); + composer.handle_key(key(KeyCode::Char('G'))); + assert_eq!(composer.cursor().1, 1); + } + + #[test] + fn word_delete_is_a_real_buffer_undo_transaction() { + let mut composer = ModalComposer::new("one two three", vec![]); + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('0'))); + composer.handle_key(key(KeyCode::Char('d'))); + composer.handle_key(key(KeyCode::Char('w'))); + assert_eq!(composer.contents(), "two three"); + composer.handle_key(key(KeyCode::Char('u'))); + assert_eq!(composer.contents(), "one two three"); + composer.handle_key(control('r')); + assert_eq!(composer.contents(), "two three"); + } + + #[test] + fn change_inner_word_enters_insert_mode_and_preserves_undo() { + let mut composer = ModalComposer::new("one target three", vec![]); + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('0'))); + composer.handle_key(key(KeyCode::Char('w'))); + composer.handle_key(key(KeyCode::Char('c'))); + composer.handle_key(key(KeyCode::Char('i'))); + composer.handle_key(key(KeyCode::Char('w'))); + assert_eq!(composer.mode(), ModalComposerMode::Insert); + assert_eq!(composer.contents(), "one three"); + composer.handle_key(key(KeyCode::Char('x'))); + normal(&mut composer); + assert_eq!(composer.contents(), "one x three"); + composer.handle_key(key(KeyCode::Char('u'))); + assert_eq!(composer.contents(), "one target three"); + } + + #[test] + fn visual_delete_removes_the_inclusive_selected_range() { + let mut composer = ModalComposer::new("abcdef", vec![]); + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('0'))); + composer.handle_key(key(KeyCode::Char('v'))); + composer.handle_key(key(KeyCode::Char('l'))); + composer.handle_key(key(KeyCode::Char('l'))); + assert!(composer.selection_range().is_some()); + composer.handle_key(key(KeyCode::Char('d'))); + assert_eq!(composer.mode(), ModalComposerMode::Normal); + assert_eq!(composer.contents(), "def"); + composer.handle_key(key(KeyCode::Char('u'))); + assert_eq!(composer.contents(), "abcdef"); + } + + #[test] + fn unicode_backspace_and_cursor_remove_whole_graphemes() { + let mut composer = ModalComposer::new("e\u{301}👨‍👩‍👧漢", vec![]); + composer.handle_key(key(KeyCode::Backspace)); + assert_eq!(composer.contents(), "e\u{301}👨‍👩‍👧"); + composer.handle_key(key(KeyCode::Backspace)); + assert_eq!(composer.contents(), "e\u{301}"); + composer.handle_key(key(KeyCode::Backspace)); + assert_eq!(composer.contents(), ""); + } + + #[test] + fn multiline_paste_normalizes_newlines_and_undoes_as_one_insert() { + let mut composer = ModalComposer::new("prefix", vec![]); + assert_eq!( + composer.handle_paste("\r\nsecond\rthird"), + ModalComposerOutcome::Changed + ); + assert_eq!(composer.contents(), "prefix\nsecond\nthird"); + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('u'))); + assert_eq!(composer.contents(), "prefix"); + } + + #[test] + fn history_navigation_restores_original_multiline_draft() { + let mut composer = ModalComposer::new( + "draft\nline", + vec!["newer\r\nprompt".to_string(), "older".to_string()], + ); + composer.handle_key(control('p')); + assert_eq!(composer.contents(), "newer\nprompt"); + composer.handle_key(control('p')); + assert_eq!(composer.contents(), "older"); + composer.handle_key(control('n')); + assert_eq!(composer.contents(), "newer\nprompt"); + composer.handle_key(control('n')); + assert_eq!(composer.contents(), "draft\nline"); + } + + #[test] + fn oversized_paste_and_initial_draft_preserve_safe_state() { + let mut composer = ModalComposer::new("draft", vec![]); + let oversized = "x".repeat(MAX_PROMPT_BYTES); + assert_eq!( + composer.handle_paste(&oversized), + ModalComposerOutcome::Rejected + ); + assert_eq!(composer.contents(), "draft"); + assert_eq!(composer.validation_status(), Some("Prompt exceeds 128 KiB")); + + let rejected = ModalComposer::new(&"x".repeat(MAX_PROMPT_BYTES + 1), vec![]); + assert_eq!(rejected.contents(), ""); + assert_eq!(rejected.validation_status(), Some("Prompt exceeds 128 KiB")); + } + + #[test] + fn opened_lines_remain_in_insert_mode() { + let mut composer = ModalComposer::new("one\ntwo", vec![]); + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('g'))); + composer.handle_key(key(KeyCode::Char('g'))); + composer.handle_key(key(KeyCode::Char('o'))); + assert_eq!(composer.mode(), ModalComposerMode::Insert); + assert_eq!(composer.contents(), "one\n\ntwo"); + composer.handle_key(key(KeyCode::Char('x'))); + assert_eq!(composer.contents(), "one\nx\ntwo"); + } + + #[test] + fn yank_and_put_use_a_shared_real_buffer_register() { + let mut composer = ModalComposer::new("one two", vec![]); + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('0'))); + composer.handle_key(key(KeyCode::Char('y'))); + composer.handle_key(key(KeyCode::Char('w'))); + composer.handle_key(key(KeyCode::Char('$'))); + composer.handle_key(key(KeyCode::Char('p'))); + assert_eq!(composer.contents(), "one twoone "); + } + + #[test] + fn global_cursor_offset_counts_complete_unicode_graphemes() { + let composer = ModalComposer::new("e\u{301}\n👨‍👩‍👧漢", vec![]); + assert_eq!(composer.cursor(), (2, 1)); + assert_eq!(composer.cursor_grapheme_index(), 4); + } + + #[test] + fn change_inner_word_never_splits_decomposed_unicode_graphemes() { + let original = "prefix cafe\u{301} suffix"; + let mut composer = ModalComposer::new(original, vec![]); + + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('0'))); + composer.handle_key(key(KeyCode::Char('w'))); + composer.handle_key(key(KeyCode::Char('c'))); + composer.handle_key(key(KeyCode::Char('i'))); + composer.handle_key(key(KeyCode::Char('w'))); + + assert_eq!(composer.mode(), ModalComposerMode::Insert); + assert_eq!(composer.contents(), "prefix suffix"); + assert_eq!(composer.handle_paste("漢👨‍👩‍👧"), ModalComposerOutcome::Changed); + assert_eq!(composer.contents(), "prefix 漢👨‍👩‍👧 suffix"); + + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('u'))); + assert_eq!(composer.contents(), original); + } + + #[test] + fn visual_selection_deletes_whole_combining_and_family_graphemes() { + let original = "a👨‍👩‍👧e\u{301}漢z"; + let mut composer = ModalComposer::new(original, vec![]); + + normal(&mut composer); + composer.handle_key(key(KeyCode::Char('0'))); + composer.handle_key(key(KeyCode::Char('l'))); + composer.handle_key(key(KeyCode::Char('v'))); + composer.handle_key(key(KeyCode::Char('l'))); + composer.handle_key(key(KeyCode::Char('d'))); + + assert_eq!(composer.contents(), "a漢z"); + composer.handle_key(key(KeyCode::Char('u'))); + assert_eq!(composer.contents(), original); + } + + #[test] + fn markdown_paste_preserves_fences_unicode_and_exact_normalized_source() { + let mut composer = ModalComposer::new("", vec![]); + let markdown = "## Café\r\n\r\n```rust\r\nlet family = \"👨‍👩‍👧漢\";\r\n```\r\n"; + let normalized = "## Café\n\n```rust\nlet family = \"👨‍👩‍👧漢\";\n```\n"; + + assert_eq!( + composer.handle_paste(markdown), + ModalComposerOutcome::Changed + ); + assert_eq!(composer.contents(), normalized); + assert_eq!( + composer.handle_key(modified_enter(KeyModifiers::CONTROL)), + ModalComposerOutcome::Submit + ); + assert_eq!(composer.contents(), normalized); + } + + #[test] + fn taking_submission_resets_insert_mode_and_preserves_recall_history() { + let mut composer = ModalComposer::new("first\nprompt", vec![]); + normal(&mut composer); + + assert_eq!(composer.take_submission().as_deref(), Some("first\nprompt")); + assert_eq!(composer.mode(), ModalComposerMode::Insert); + assert_eq!(composer.contents(), ""); + + composer.handle_paste("unfinished draft"); + composer.handle_key(control('p')); + assert_eq!(composer.contents(), "first\nprompt"); + composer.handle_key(control('n')); + assert_eq!(composer.contents(), "unfinished draft"); + } + + #[test] + fn setting_multiline_mouse_cursor_never_splits_unicode_graphemes() { + let mut composer = ModalComposer::new("a👨‍👩‍👧\ne\u{301}漢", vec![]); + + composer.set_cursor_grapheme_index(1); + assert_eq!(composer.cursor(), (1, 0)); + composer.handle_paste("X"); + assert_eq!(composer.contents(), "aX👨‍👩‍👧\ne\u{301}漢"); + + composer.set_cursor_grapheme_index(usize::MAX); + assert_eq!(composer.cursor(), (2, 1)); + assert_eq!(composer.cursor_grapheme_index(), 6); + } +} diff --git a/tests/codex_app_server.rs b/tests/codex_app_server.rs index e2ae7069..25186c18 100644 --- a/tests/codex_app_server.rs +++ b/tests/codex_app_server.rs @@ -9,7 +9,10 @@ use std::{ use async_trait::async_trait; use red::{ agent_tools::EditorToolRequest, - codex::{start_codex, CodexCommand, CodexEvent, CodexProcessSpec, CodexToolHost}, + codex::{ + start_codex, CodexBridge, CodexCommand, CodexEvent, CodexExecutionMode, CodexProcessSpec, + CodexToolHost, + }, }; use serde_json::{json, Value}; @@ -46,10 +49,20 @@ import json, os, sys assert "features.hooks=false" not in sys.argv assert "features.codex_hooks=false" not in sys.argv +mode = os.environ.get("RED_MOCK_MODE", "review") +scenario = os.environ.get("RED_MOCK_SCENARIO", "write") +resume_attempts = 0 +config_reads = 0 def send(value): print(json.dumps(value), flush=True) +def complete(status="completed", thread_id="thread-red"): + send({"method": "turn/completed", "params": { + "threadId": thread_id, + "turn": {"id": "turn-red", "status": status} + }}) + for line in sys.stdin: message = json.loads(line) method = message.get("method") @@ -61,37 +74,136 @@ for line in sys.stdin: elif method == "account/read": send({"id": ident, "result": {"account": {"type": "chatgpt"}, "requiresOpenaiAuth": True}}) elif method == "config/read": + config_reads += 1 send({"id": ident, "result": {"config": {"mcp_servers": {}}, "origins": {}}}) elif method == "configRequirements/read": + assert mode == "review" requirements = json.loads(os.environ.get("RED_MOCK_REQUIREMENTS", "null")) send({"id": ident, "result": {"requirements": requirements}}) elif method == "thread/start": - assert message["params"]["sandbox"] == "read-only" - assert message["params"]["approvalPolicy"] == "never" - assert len(message["params"]["dynamicTools"]) == 9 - expected_hooks = os.environ.get("RED_MOCK_EXPECT_HOOKS") == "true" - assert message["params"]["config"]["features"]["hooks"] is expected_hooks - assert "codex_hooks" not in message["params"]["config"]["features"] - send({"id": ident, "result": {"thread": {"id": "thread-red"}}}) + if os.environ.get("RED_MOCK_EXPECT_RECOVERY") == "true": + assert resume_attempts == 1 + assert config_reads == 1 + assert os.environ.get("RED_MOCK_FORBID_RECOVERY") != "true" + params = message["params"] + assert len(params["dynamicTools"]) == 9 + if mode == "review": + assert params["sandbox"] == "read-only" + assert params["approvalPolicy"] == "never" + expected_hooks = os.environ.get("RED_MOCK_EXPECT_HOOKS") == "true" + assert params["config"]["features"]["hooks"] is expected_hooks + assert "codex_hooks" not in params["config"]["features"] + else: + for overridden in ["sandbox", "approvalPolicy", "environments", "config", "baseInstructions"]: + assert overridden not in params, overridden + for disabled in ["features.apps=false", "features.connectors=false", "features.plugins=false", "features.remote_plugin=false"]: + assert disabled not in sys.argv, disabled + assert bool(params.get("ephemeral", False)) is ( + os.environ.get("RED_MOCK_EXPECT_EPHEMERAL") == "true" + ) + send({"id": ident, "result": { + "thread": {"id": "thread-red"}, "model": "mock-default-model" + }}) + elif method == "thread/resume": + resume_attempts += 1 + thread_id = message["params"]["threadId"] + if thread_id == "missing-red": + send({"id": ident, "error": { + "code": -32000, "message": "persisted thread was not found" + }}) + elif thread_id == "mismatched-red": + send({"id": ident, "result": { + "thread": {"id": "unexpected-red"}, "model": "mock-default-model" + }}) + else: + assert thread_id == "persisted-red" + send({"id": ident, "result": { + "thread": {"id": "persisted-red"}, "model": "mock-default-model" + }}) + elif method == "model/list": + send({"id": ident, "result": {"data": [{ + "id": "mock-selected-model", + "model": "mock-selected-model", + "displayName": "Mock Selected Model", + "supportedReasoningEfforts": [{"reasoningEffort": "high"}], + "defaultReasoningEffort": "medium" + }], "nextCursor": None}}) + elif method == "thread/list": + assert message["params"]["archived"] is False + assert message["params"]["limit"] == 50 + send({"id": ident, "result": {"data": [{ + "id": "persisted-red", "preview": "Restored conversation" + }], "nextCursor": None}}) elif method == "turn/start": - text = message["params"]["input"][0]["text"] - assert "Active editor context from red-buffer://active:" in text - assert "unsaved editor text" in text + params = message["params"] + if mode == "review": + assert params["approvalPolicy"] == "never" + assert params["sandboxPolicy"] == {"type": "readOnly"} + else: + assert "approvalPolicy" not in params + assert "sandboxPolicy" not in params + if scenario == "write": + text = params["input"][0]["text"] + assert "Active editor context from red-buffer://active:" in text + assert "unsaved editor text" in text + expected_model = os.environ.get("RED_MOCK_EXPECT_MODEL") + if expected_model is not None: + assert params["model"] == expected_model + expected_effort = os.environ.get("RED_MOCK_EXPECT_EFFORT") + if expected_effort is not None: + assert params["effort"] == expected_effort send({"id": ident, "result": {"turn": {"id": "turn-red"}}}) - send({"method": "item/agentMessage/delta", "params": { - "threadId": "thread-red", "turnId": "turn-red", "delta": "working" - }}) - send({"id": "tool-write", "method": "item/tool/call", "params": { - "threadId": "thread-red", "turnId": "turn-red", - "tool": "write_file", - "arguments": {"path": "src/main.rs", "content": "proposed\n"} + send({"method": "turn/started", "params": { + "threadId": "thread-red", "turn": {"id": "turn-red"} }}) + if scenario == "write": + send({"method": "item/agentMessage/delta", "params": { + "threadId": "thread-red", "turnId": "turn-red", "delta": "working" + }}) + send({"id": "tool-write", "method": "item/tool/call", "params": { + "threadId": "thread-red", "turnId": "turn-red", + "tool": "write_file", + "arguments": {"path": "src/main.rs", "content": "proposed\n"} + }}) + elif scenario == "approval": + send({"id": "approve-command", "method": "item/commandExecution/requestApproval", "params": { + "threadId": "thread-red", "turnId": "turn-red", "itemId": "command-1", + "command": "cargo test", "availableDecisions": ["accept", "decline"] + }}) + elif scenario == "activity": + item = {"id": "command-1", "type": "commandExecution", "command": "cargo test"} + send({"method": "item/started", "params": { + "threadId": "thread-red", "turnId": "turn-red", "item": item + }}) + send({"method": "item/reasoning/summaryTextDelta", "params": { + "threadId": "thread-red", "turnId": "turn-red", + "itemId": "reasoning-1", "delta": "public reasoning summary" + }}) + send({"method": "item/reasoning/textDelta", "params": { + "threadId": "thread-red", "turnId": "turn-red", + "itemId": "reasoning-1", "delta": "private reasoning must stay hidden" + }}) + send({"method": "item/completed", "params": { + "threadId": "thread-red", "turnId": "turn-red", + "item": {**item, "status": "completed"} + }}) + send({"method": "item/agentMessage/delta", "params": { + "threadId": "thread-red", "turnId": "turn-red", "delta": "working" + }}) + complete() + elif method == "turn/steer": + assert message["params"]["expectedTurnId"] == "turn-red" + send({"id": ident, "result": {"turnId": "turn-red"}}) + elif method == "turn/interrupt": + assert message["params"]["turnId"] == "turn-red" + send({"id": ident, "result": {}}) + complete("interrupted") elif ident == "tool-write": assert message["result"]["success"] is True - send({"method": "turn/completed", "params": { - "threadId": "thread-red", - "turn": {"id": "turn-red", "status": "completed"} - }}) + complete() + elif ident == "approve-command": + assert message["result"]["decision"] == os.environ.get("RED_MOCK_EXPECT_DECISION", "accept") + complete() "#, ) .unwrap(); @@ -101,6 +213,59 @@ for line in sys.stdin: path } +async fn next_event(bridge: &mut CodexBridge) -> CodexEvent { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + if let Some(event) = bridge.try_recv() { + return event; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("mock app-server did not produce an event") +} + +fn recording_host() -> RecordingHost { + RecordingHost { + writes: Arc::new(Mutex::new(Vec::new())), + } +} + +async fn create_session(bridge: &mut CodexBridge, directory: &std::path::Path) -> String { + bridge + .send(CodexCommand::NewSession { + cwd: directory.to_path_buf(), + }) + .await + .unwrap(); + match next_event(bridge).await { + CodexEvent::SessionCreated { session_id } => session_id, + CodexEvent::Failed { message, .. } => panic!("mock session failed: {message}"), + other => panic!("unexpected session event: {other:?}"), + } +} + +fn mock_spec( + codex: std::path::PathBuf, + directory: &std::path::Path, + mode: CodexExecutionMode, + scenario: &str, +) -> CodexProcessSpec { + let mut spec = CodexProcessSpec::new(codex, directory).execution_mode(mode); + spec.environment.insert( + "RED_MOCK_MODE".into(), + match mode { + CodexExecutionMode::Native => "native", + CodexExecutionMode::ReviewSafe => "review", + } + .into(), + ); + spec.environment + .insert("RED_MOCK_SCENARIO".into(), scenario.into()); + spec +} + #[tokio::test] async fn direct_app_server_streams_and_routes_writes_to_the_host() { let directory = tempfile::tempdir().unwrap(); @@ -110,7 +275,12 @@ async fn direct_app_server_streams_and_routes_writes_to_the_host() { writes: Arc::clone(&writes), }; let (mut bridge, task) = start_codex( - CodexProcessSpec::new(codex, directory.path()), + mock_spec( + codex, + directory.path(), + CodexExecutionMode::ReviewSafe, + "write", + ), host, NonZeroUsize::new(32).unwrap(), ) @@ -168,7 +338,12 @@ async fn direct_app_server_starts_with_required_hooks() { let host = RecordingHost { writes: Arc::new(Mutex::new(Vec::new())), }; - let mut spec = CodexProcessSpec::new(codex, directory.path()); + let mut spec = mock_spec( + codex, + directory.path(), + CodexExecutionMode::ReviewSafe, + "write", + ); spec.environment.insert( "RED_MOCK_REQUIREMENTS".into(), json!({ @@ -205,3 +380,487 @@ async fn direct_app_server_starts_with_required_hooks() { drop(bridge); task.await.unwrap().unwrap(); } + +#[tokio::test] +async fn native_app_server_inherits_codex_policy_and_persists_threads() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let mut spec = CodexProcessSpec::new(codex, directory.path()); + assert_eq!(spec.execution_mode, CodexExecutionMode::Native); + assert!(spec.persistent_threads); + spec.environment + .insert("RED_MOCK_MODE".into(), "native".into()); + spec.environment + .insert("RED_MOCK_SCENARIO".into(), "idle".into()); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + + assert_eq!( + create_session(&mut bridge, directory.path()).await, + "thread-red" + ); + + drop(bridge); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn native_app_server_can_explicitly_request_ephemeral_threads() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let mut spec = mock_spec(codex, directory.path(), CodexExecutionMode::Native, "idle") + .persistent_threads(false); + spec.environment + .insert("RED_MOCK_EXPECT_EPHEMERAL".into(), "true".into()); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + + assert_eq!( + create_session(&mut bridge, directory.path()).await, + "thread-red" + ); + + drop(bridge); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn native_app_server_surfaces_and_resolves_explicit_command_approvals() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let spec = mock_spec( + codex, + directory.path(), + CodexExecutionMode::Native, + "approval", + ); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + let session_id = create_session(&mut bridge, directory.path()).await; + bridge + .send(CodexCommand::Prompt { + session_id: session_id.clone(), + text: "run the focused tests".to_string(), + }) + .await + .unwrap(); + + let mut approved = false; + loop { + match next_event(&mut bridge).await { + CodexEvent::PermissionRequested { + request_id, + session_id: owner, + tool_call, + options, + } => { + assert_eq!(owner, session_id); + assert_eq!(request_id, "approve-command"); + assert_eq!(tool_call["command"], "cargo test"); + let options = options.as_array().unwrap(); + assert_eq!(options.len(), 2); + assert!(options.iter().any(|option| option["option_id"] == "accept")); + assert!(options + .iter() + .any(|option| option["option_id"] == "decline")); + bridge + .send(CodexCommand::PermissionResponse { + request_id, + option_id: Some("accept".to_string()), + }) + .await + .unwrap(); + approved = true; + } + CodexEvent::Completed { stop_reason, .. } => { + assert_eq!(stop_reason, "completed"); + break; + } + CodexEvent::Failed { message, .. } => panic!("approval failed: {message}"), + _ => {} + } + } + assert!(approved, "native command approval was not presented"); + + drop(bridge); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn unavailable_command_approval_choices_fail_closed() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let mut spec = mock_spec( + codex, + directory.path(), + CodexExecutionMode::Native, + "approval", + ); + spec.environment + .insert("RED_MOCK_EXPECT_DECISION".into(), "decline".into()); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + let session_id = create_session(&mut bridge, directory.path()).await; + bridge + .send(CodexCommand::Prompt { + session_id, + text: "run the focused tests".to_string(), + }) + .await + .unwrap(); + + loop { + match next_event(&mut bridge).await { + CodexEvent::PermissionRequested { request_id, .. } => { + bridge + .send(CodexCommand::PermissionResponse { + request_id, + option_id: Some("not-offered".to_string()), + }) + .await + .unwrap(); + } + CodexEvent::Completed { stop_reason, .. } => { + assert_eq!(stop_reason, "completed"); + break; + } + CodexEvent::Failed { message, .. } => panic!("denial failed: {message}"), + _ => {} + } + } + + drop(bridge); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn review_safe_app_server_automatically_declines_native_approvals() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let mut spec = mock_spec( + codex, + directory.path(), + CodexExecutionMode::ReviewSafe, + "approval", + ); + spec.environment + .insert("RED_MOCK_EXPECT_DECISION".into(), "decline".into()); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + let session_id = create_session(&mut bridge, directory.path()).await; + bridge + .send(CodexCommand::Prompt { + session_id, + text: "inspect the focused tests".to_string(), + }) + .await + .unwrap(); + + loop { + match next_event(&mut bridge).await { + CodexEvent::PermissionRequested { .. } => { + panic!("review-safe mode surfaced a native command approval") + } + CodexEvent::Completed { stop_reason, .. } => { + assert_eq!(stop_reason, "completed"); + break; + } + CodexEvent::Failed { message, .. } => panic!("review-safe turn failed: {message}"), + _ => {} + } + } + + drop(bridge); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn model_selection_applies_to_turns_and_hides_private_reasoning() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let mut spec = mock_spec( + codex, + directory.path(), + CodexExecutionMode::Native, + "activity", + ); + spec.environment + .insert("RED_MOCK_EXPECT_MODEL".into(), "mock-selected-model".into()); + spec.environment + .insert("RED_MOCK_EXPECT_EFFORT".into(), "high".into()); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + let session_id = create_session(&mut bridge, directory.path()).await; + + bridge + .send(CodexCommand::ListModels { + session_id: session_id.clone(), + }) + .await + .unwrap(); + match next_event(&mut bridge).await { + CodexEvent::Activity { update, .. } => { + assert_eq!(update["session_update"], "models"); + assert_eq!(update["models"][0]["id"], "mock-selected-model"); + assert_eq!( + update["models"][0]["supportedReasoningEfforts"][0]["reasoningEffort"], + "high" + ); + } + other => panic!("unexpected model-catalog event: {other:?}"), + } + + bridge + .send(CodexCommand::SetModel { + session_id: session_id.clone(), + model: "mock-selected-model".to_string(), + reasoning_effort: Some("high".to_string()), + }) + .await + .unwrap(); + match next_event(&mut bridge).await { + CodexEvent::Activity { update, .. } => { + assert_eq!(update["session_update"], "model_selected"); + assert_eq!(update["model"], "mock-selected-model"); + assert_eq!(update["reasoning_effort"], "high"); + } + other => panic!("unexpected model-selection event: {other:?}"), + } + + bridge + .send(CodexCommand::Prompt { + session_id, + text: "explain the focused tests".to_string(), + }) + .await + .unwrap(); + let mut saw_public_reasoning = false; + let mut saw_command_start = false; + let mut saw_command_completion = false; + let mut streamed = String::new(); + loop { + match next_event(&mut bridge).await { + CodexEvent::Activity { update, .. } => { + assert!( + !update + .to_string() + .contains("private reasoning must stay hidden"), + "raw reasoning was disclosed" + ); + match update["session_update"].as_str() { + Some("agent_thought_chunk") => { + assert_eq!(update["content"]["text"], "public reasoning summary"); + saw_public_reasoning = true; + } + Some("tool_call") => { + assert_eq!(update["title"], "Running cargo test"); + saw_command_start = true; + } + Some("tool_call_update") => { + assert_eq!(update["status"], "completed"); + saw_command_completion = true; + } + _ => {} + } + } + CodexEvent::Update { text, .. } => streamed.push_str(&text), + CodexEvent::Completed { stop_reason, .. } => { + assert_eq!(stop_reason, "completed"); + break; + } + CodexEvent::Failed { message, .. } => panic!("model turn failed: {message}"), + _ => {} + } + } + assert!(saw_public_reasoning); + assert!(saw_command_start); + assert!(saw_command_completion); + assert_eq!(streamed, "working"); + + drop(bridge); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn durable_threads_can_be_listed_and_resumed() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let spec = mock_spec(codex, directory.path(), CodexExecutionMode::Native, "idle"); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + let session_id = create_session(&mut bridge, directory.path()).await; + + bridge + .send(CodexCommand::ListSessions { + session_id, + cwd: directory.path().to_path_buf(), + }) + .await + .unwrap(); + match next_event(&mut bridge).await { + CodexEvent::Activity { update, .. } => { + assert_eq!(update["session_update"], "sessions"); + assert_eq!(update["sessions"][0]["id"], "persisted-red"); + } + other => panic!("unexpected session-list event: {other:?}"), + } + + bridge + .send(CodexCommand::ResumeSession { + session_id: "persisted-red".to_string(), + cwd: directory.path().to_path_buf(), + }) + .await + .unwrap(); + match next_event(&mut bridge).await { + CodexEvent::SessionCreated { session_id } => assert_eq!(session_id, "persisted-red"), + other => panic!("unexpected resume event: {other:?}"), + } + + drop(bridge); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn active_turn_can_be_steered_and_cancelled_to_a_terminal_state() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let spec = mock_spec( + codex, + directory.path(), + CodexExecutionMode::Native, + "interrupt", + ); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + let session_id = create_session(&mut bridge, directory.path()).await; + bridge + .send(CodexCommand::Prompt { + session_id: session_id.clone(), + text: "start the focused tests".to_string(), + }) + .await + .unwrap(); + + match next_event(&mut bridge).await { + CodexEvent::Activity { update, .. } => { + assert_eq!(update["session_update"], "turn_started"); + } + other => panic!("unexpected turn-start event: {other:?}"), + } + bridge + .send(CodexCommand::Steer { + session_id: session_id.clone(), + text: "run only app-server tests".to_string(), + }) + .await + .unwrap(); + match next_event(&mut bridge).await { + CodexEvent::Activity { update, .. } => { + assert_eq!(update["session_update"], "steer"); + assert_eq!(update["turn_id"], "turn-red"); + } + other => panic!("unexpected steering event: {other:?}"), + } + + bridge + .send(CodexCommand::Cancel { session_id }) + .await + .unwrap(); + let mut acknowledged = false; + loop { + match next_event(&mut bridge).await { + CodexEvent::Cancelled { .. } => acknowledged = true, + CodexEvent::Completed { stop_reason, .. } => { + assert_eq!(stop_reason, "interrupted"); + break; + } + CodexEvent::Failed { message, .. } => panic!("interrupt failed: {message}"), + _ => {} + } + } + assert!(acknowledged, "interrupt was not acknowledged"); + + drop(bridge); + task.await.unwrap().unwrap(); +} + +async fn assert_unavailable_session_recovers_once( + mode: CodexExecutionMode, + unavailable_thread: &str, +) { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let mut spec = mock_spec(codex, directory.path(), mode, "idle"); + spec.environment + .insert("RED_MOCK_EXPECT_RECOVERY".into(), "true".into()); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + + bridge + .send(CodexCommand::RecoverSession { + session_id: unavailable_thread.to_string(), + cwd: directory.path().to_path_buf(), + }) + .await + .unwrap(); + + match next_event(&mut bridge).await { + CodexEvent::SessionCreated { session_id } => assert_eq!(session_id, "thread-red"), + CodexEvent::Failed { message, .. } => { + panic!("automatic session recovery was not attempted: {message}") + } + other => panic!("unexpected recovery event: {other:?}"), + } + + drop(bridge); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn native_missing_session_recovers_with_inherited_codex_policy() { + assert_unavailable_session_recovers_once(CodexExecutionMode::Native, "missing-red").await; +} + +#[tokio::test] +async fn review_safe_missing_session_recovers_with_restricted_policy() { + assert_unavailable_session_recovers_once(CodexExecutionMode::ReviewSafe, "missing-red").await; +} + +#[tokio::test] +async fn mismatched_recovered_thread_starts_exactly_one_fresh_session() { + assert_unavailable_session_recovers_once(CodexExecutionMode::Native, "mismatched-red").await; +} + +#[tokio::test] +async fn explicit_resume_of_a_missing_thread_fails_without_creating_a_session() { + let directory = tempfile::tempdir().unwrap(); + let codex = mock_codex(directory.path()); + let mut spec = mock_spec(codex, directory.path(), CodexExecutionMode::Native, "idle"); + spec.environment + .insert("RED_MOCK_FORBID_RECOVERY".into(), "true".into()); + let (mut bridge, task) = + start_codex(spec, recording_host(), NonZeroUsize::new(32).unwrap()).unwrap(); + + bridge + .send(CodexCommand::ResumeSession { + session_id: "missing-red".to_string(), + cwd: directory.path().to_path_buf(), + }) + .await + .unwrap(); + + match next_event(&mut bridge).await { + CodexEvent::Failed { + session_id, + message, + } => { + assert_eq!(session_id.as_deref(), Some("missing-red")); + assert!(message.contains("not found"), "{message}"); + } + other => panic!("explicit resume must fail rather than recover: {other:?}"), + } + + drop(bridge); + task.await.unwrap().unwrap(); +} diff --git a/tests/editing.rs b/tests/editing.rs index 62d66cfc..9e22a81c 100644 --- a/tests/editing.rs +++ b/tests/editing.rs @@ -2,7 +2,7 @@ mod common; use common::{EditorHarness, LspEvent, MockLsp, RecordingLsp}; use crossterm::event::{ - Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use red::{ agent_tools::{ @@ -13,7 +13,7 @@ use red::{ buffer::{Buffer, SyntaxSelection}, clipboard::MemoryClipboardProvider, color::Color, - config::{Config, KeyAction, MatchitLanguageConfig}, + config::{AgentPosition, Config, CursorShape, KeyAction, MatchitLanguageConfig}, editor::{Action, Content, Editor, Mode, SearchDirection}, lsp::LspClient, plugin::{ @@ -5296,6 +5296,636 @@ async fn shifted_window_chords_move_nested_splits_to_each_outer_edge() { } } +#[tokio::test] +async fn shifted_window_chords_move_the_focused_agent_to_each_edge_without_losing_its_draft() { + let conversation_keys = [KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)]; + let normal_composer_keys = [KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)]; + let visual_composer_keys = [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), + ]; + + for (focus, focus_keys, cursor_shape) in [ + ( + "conversation", + conversation_keys.as_slice(), + CursorShape::SteadyBlock, + ), + ( + "Normal composer", + normal_composer_keys.as_slice(), + CursorShape::SteadyBlock, + ), + ( + "Visual composer", + visual_composer_keys.as_slice(), + CursorShape::BlinkingUnderscore, + ), + ] { + let mut config = default_key_config(); + config.cursor.normal = CursorShape::SteadyBlock; + config.cursor.insert = CursorShape::SteadyBar; + config.cursor.visual = CursorShape::BlinkingUnderscore; + let mut harness = + EditorHarness::with_config(Buffer::new(None, "background editor".to_string()), config); + harness.editor.test_create_text_panel( + "agent-conversation", + PanelConfig { + side: PanelSide::Right, + width: 30, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up".to_string(), + rows: 2, + }), + ..PanelConfig::default() + }, + ); + assert!(harness + .editor + .test_focus_text_panel_composer("agent-conversation")); + + let draft = "preserve λ draft"; + harness + .editor + .test_handle_event(Event::Paste(draft.to_string())) + .unwrap(); + for key in focus_keys { + harness.editor.test_handle_event(Event::Key(*key)).unwrap(); + } + let window_layout = harness.editor.test_session_snapshot().window_layout; + let editor_cursor = harness.cursor_position(); + let editor_viewport = harness.viewport_top(); + + for (key, expected_position, expected_origin, expected_size) in [ + ('H', AgentPosition::Left, (31, 0), (49, 22)), + ('J', AgentPosition::Bottom, (0, 0), (80, 14)), + ('K', AgentPosition::Top, (0, 8), (80, 14)), + ('L', AgentPosition::Right, (0, 0), (49, 22)), + ] { + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::CONTROL, + ))) + .await + .unwrap(); + assert!( + harness.is_waiting_for_key_sequence(), + "Ctrl-w must start the window chord from the {focus}" + ); + + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char(key), + KeyModifiers::SHIFT, + ))) + .await + .unwrap(); + + assert!(!harness.is_waiting_for_key_sequence()); + assert_eq!( + harness.editor.test_agent_position(), + expected_position, + "Ctrl-w {key} must update the persistent agent position" + ); + assert_eq!( + harness.editor.test_focused_panel_id(), + Some("agent-conversation"), + "Ctrl-w {key} must preserve agent focus" + ); + assert_eq!( + harness.editor.test_active_cursor_shape(), + cursor_shape, + "Ctrl-w {key} must preserve the {focus} cursor" + ); + + let (cursor_x, cursor_y) = harness + .render_cursor_position() + .expect("the moved agent must retain a visible cursor"); + assert!( + match expected_position { + AgentPosition::Left => cursor_x < 30, + AgentPosition::Right => cursor_x >= 50, + AgentPosition::Top => cursor_y < 7, + AgentPosition::Bottom => cursor_y >= 15, + }, + "Ctrl-w {key} must move the visible agent cursor into the requested dock" + ); + + let (origin, size) = harness.editor.test_active_window_bounds().unwrap(); + assert_eq!((origin.x, origin.y), expected_origin); + assert_eq!(size, expected_size); + let updated_layout = harness.editor.test_session_snapshot().window_layout; + assert_eq!( + updated_layout.active_window_id, + window_layout.active_window_id + ); + assert!(matches!(updated_layout.root, SplitSnapshot::Window { .. })); + assert_eq!(harness.window_count(), 1); + assert_eq!(harness.cursor_position(), editor_cursor); + assert_eq!(harness.viewport_top(), editor_viewport); + assert_eq!(harness.buffer_contents(), "background editor"); + assert!( + (0..22).any(|row| { harness.editor.test_render_row(row).unwrap().contains(draft) }), + "Ctrl-w {key} must preserve and reflow the unsubmitted agent draft" + ); + } + } +} + +#[tokio::test] +async fn insert_mode_agent_ctrl_w_deletes_a_word_without_starting_a_window_chord() { + let mut config = default_key_config(); + config.cursor.insert = CursorShape::SteadyBar; + let mut harness = + EditorHarness::with_config(Buffer::new(None, "background editor".to_string()), config); + harness.editor.test_create_text_panel( + "agent-conversation", + PanelConfig { + side: PanelSide::Right, + width: 30, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up".to_string(), + rows: 2, + }), + ..PanelConfig::default() + }, + ); + assert!(harness + .editor + .test_focus_text_panel_composer("agent-conversation")); + harness + .editor + .test_handle_event(Event::Paste("keep this word".to_string())) + .unwrap(); + + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::CONTROL, + ))) + .await + .unwrap(); + + assert!(!harness.is_waiting_for_key_sequence()); + assert_eq!(harness.editor.test_agent_position(), AgentPosition::Right); + assert_eq!( + harness.editor.test_focused_panel_id(), + Some("agent-conversation") + ); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBar + ); + assert!(harness.render_cursor_position().is_some()); + assert!( + (0..22).any(|row| { + harness + .editor + .test_render_row(row) + .unwrap() + .contains("keep this") + }), + "Insert-mode Ctrl-w must keep the preceding draft words" + ); + assert!( + !(0..22).any(|row| { + harness + .editor + .test_render_row(row) + .unwrap() + .contains("keep this word") + }), + "Insert-mode Ctrl-w must delete the last draft word" + ); + assert_eq!(harness.buffer_contents(), "background editor"); +} + +#[test] +fn control_enter_submits_the_complete_insert_draft_in_every_agent_dock() { + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + for code in [KeyCode::Enter, KeyCode::Char('\n'), KeyCode::Char('\r')] { + for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { + let mut config = default_key_config(); + config.cursor.insert = CursorShape::SteadyBar; + config.agent.position = match side { + PanelSide::Left => AgentPosition::Left, + PanelSide::Right => AgentPosition::Right, + PanelSide::Top => AgentPosition::Top, + PanelSide::Bottom => AgentPosition::Bottom, + }; + let mut harness = EditorHarness::with_config( + Buffer::new(None, "background editor".to_string()), + config, + ); + harness.editor.test_create_text_panel( + "agent-conversation", + PanelConfig { + side, + width: if matches!(side, PanelSide::Top | PanelSide::Bottom) { + 8 + } else { + 30 + }, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up".to_string(), + rows: 3, + }), + ..PanelConfig::default() + }, + ); + assert!( + harness + .editor + .test_focus_text_panel_composer("agent-conversation") + ); + harness + .editor + .test_handle_event(Event::Paste("first\r\n漢👨‍👩‍👧\r\nsecond".to_string())) + .unwrap(); + + let action = harness + .editor + .test_handle_event(Event::Key(KeyEvent::new_with_kind( + code, + KeyModifiers::CONTROL, + kind, + ))) + .unwrap(); + + assert!( + matches!( + action, + Some(KeyAction::Multiple(actions)) + if actions.iter().any(|action| matches!( + action, + Action::NotifyPlugins(name, payload) + if name == "panel:event:agent-conversation" + && payload["action"] == "submit" + && payload["text"] == "first\n漢👨‍👩‍👧\nsecond" + )) + && actions.iter().any(|action| matches!(action, Action::Refresh)) + ), + "{code:?} with Ctrl and {kind:?} must submit the complete Insert draft from the {side:?} dock" + ); + assert_eq!( + harness.editor.test_focused_panel_id(), + Some("agent-conversation") + ); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBar + ); + assert!(harness.render_cursor_position().is_some()); + assert_eq!(harness.buffer_contents(), "background editor"); + } + } + } +} + +#[tokio::test] +async fn control_enter_press_and_repeat_submit_through_the_agent_editor_event_loop() { + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + for code in [KeyCode::Enter, KeyCode::Char('\n'), KeyCode::Char('\r')] { + for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { + let mut config = default_key_config(); + config.cursor.insert = CursorShape::SteadyBar; + config.agent.position = match side { + PanelSide::Left => AgentPosition::Left, + PanelSide::Right => AgentPosition::Right, + PanelSide::Top => AgentPosition::Top, + PanelSide::Bottom => AgentPosition::Bottom, + }; + let mut harness = EditorHarness::with_config( + Buffer::new(None, "background editor".to_string()), + config, + ); + harness.editor.test_create_text_panel( + "agent-conversation", + PanelConfig { + side, + width: if matches!(side, PanelSide::Top | PanelSide::Bottom) { + 8 + } else { + 30 + }, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up".to_string(), + rows: 3, + }), + ..PanelConfig::default() + }, + ); + assert!( + harness + .editor + .test_focus_text_panel_composer("agent-conversation") + ); + harness + .execute_event(Event::Paste("submit λ draft".to_string())) + .await + .unwrap(); + + harness + .execute_event(Event::Key(KeyEvent::new_with_kind( + code, + KeyModifiers::CONTROL, + KeyEventKind::Release, + ))) + .await + .unwrap(); + assert!( + (0..22).any(|row| { + harness + .editor + .test_render_row(row) + .unwrap() + .contains("submit λ draft") + }), + "a release event must not submit the Insert draft" + ); + + harness + .execute_event(Event::Key(KeyEvent::new_with_kind( + code, + KeyModifiers::CONTROL, + kind, + ))) + .await + .unwrap(); + + assert!( + !(0..22).any(|row| { + harness + .editor + .test_render_row(row) + .unwrap() + .contains("submit λ draft") + }), + "{code:?} with Ctrl and {kind:?} must submit, not append a newline, from the {side:?} dock" + ); + assert_eq!( + harness.editor.test_focused_panel_id(), + Some("agent-conversation") + ); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBar + ); + assert!(harness.render_cursor_position().is_some()); + assert_eq!(harness.buffer_contents(), "background editor"); + } + } + } +} + +#[test] +fn control_j_and_unmodified_enter_still_add_lines_in_every_agent_dock() { + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + for (code, modifiers) in [ + (KeyCode::Char('j'), KeyModifiers::CONTROL), + (KeyCode::Enter, KeyModifiers::NONE), + (KeyCode::Enter, KeyModifiers::SHIFT), + ] { + let mut config = default_key_config(); + config.cursor.insert = CursorShape::SteadyBar; + config.agent.position = match side { + PanelSide::Left => AgentPosition::Left, + PanelSide::Right => AgentPosition::Right, + PanelSide::Top => AgentPosition::Top, + PanelSide::Bottom => AgentPosition::Bottom, + }; + let mut harness = EditorHarness::with_config( + Buffer::new(None, "background editor".to_string()), + config, + ); + harness.editor.test_create_text_panel( + "agent-conversation", + PanelConfig { + side, + width: if matches!(side, PanelSide::Top | PanelSide::Bottom) { + 8 + } else { + 30 + }, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up".to_string(), + rows: 3, + }), + ..PanelConfig::default() + }, + ); + assert!( + harness + .editor + .test_focus_text_panel_composer("agent-conversation") + ); + harness + .editor + .test_handle_event(Event::Paste("keep λ draft".to_string())) + .unwrap(); + + let action = harness + .editor + .test_handle_event(Event::Key(KeyEvent::new(code, modifiers))) + .unwrap(); + + assert!( + matches!( + action, + Some(KeyAction::Multiple(actions)) + if actions.iter().any(|action| matches!( + action, + Action::NotifyPlugins(name, payload) + if name == "panel:event:agent-conversation" + && payload["action"] == "composer_input" + )) + ), + "{code:?} with {modifiers:?} must remain a newline in the {side:?} Insert composer" + ); + assert_eq!( + harness.editor.test_focused_panel_id(), + Some("agent-conversation") + ); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBar + ); + assert!( + (0..22).any(|row| { + harness + .editor + .test_render_row(row) + .unwrap() + .contains("keep λ draft") + }), + "newline keys must retain the unsubmitted draft" + ); + assert_eq!(harness.buffer_contents(), "background editor"); + } + } +} + +#[tokio::test] +async fn shifted_agent_window_chords_preserve_responsive_narrow_terminal_layout() { + let mut config = default_key_config(); + config.cursor.normal = CursorShape::SteadyBlock; + let mut harness = EditorHarness::with_config_and_size( + Buffer::new(None, "background editor".to_string()), + config, + 50, + 24, + ); + harness.editor.test_create_text_panel( + "agent-conversation", + PanelConfig { + side: PanelSide::Right, + width: 30, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask a follow-up".to_string(), + rows: 2, + }), + ..PanelConfig::default() + }, + ); + assert!(harness.editor.test_focus_panel("agent-conversation")); + + for (key, expected_position, expected_cursor_at_top) in [ + ('H', AgentPosition::Left, false), + ('K', AgentPosition::Top, true), + ('L', AgentPosition::Right, false), + ] { + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::CONTROL, + ))) + .await + .unwrap(); + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char(key), + KeyModifiers::SHIFT, + ))) + .await + .unwrap(); + + assert_eq!(harness.editor.test_agent_position(), expected_position); + assert_eq!( + harness.editor.test_focused_panel_id(), + Some("agent-conversation") + ); + let (_, cursor_y) = harness + .render_cursor_position() + .expect("the responsive agent dock must retain its visible cursor"); + assert_eq!( + cursor_y < 7, + expected_cursor_at_top, + "narrow side docks must fall back to the bottom without discarding the preferred side" + ); + } + + harness.execute_event(Event::Resize(80, 24)).await.unwrap(); + + assert_eq!(harness.editor.test_agent_position(), AgentPosition::Right); + let (cursor_x, _) = harness + .render_cursor_position() + .expect("widening the terminal must keep the agent cursor visible"); + assert!(cursor_x >= 50); + let (origin, size) = harness.editor.test_active_window_bounds().unwrap(); + assert_eq!((origin.x, origin.y), (0, 0)); + assert_eq!(size, (49, 22)); +} + +#[tokio::test] +async fn shifted_window_chords_do_not_reposition_row_or_unrelated_text_panels() { + for row_panel in [false, true] { + let mut harness = EditorHarness::with_config( + Buffer::new(None, "first\nsecond\nthird\n".to_string()), + default_key_config(), + ); + harness.execute_action(Action::SplitVertical).await.unwrap(); + harness + .execute_action(Action::SplitHorizontal) + .await + .unwrap(); + + let id = if row_panel { + add_tree_panel(&mut harness); + "tree" + } else { + harness.editor.test_create_text_panel( + "notes", + PanelConfig { + side: PanelSide::Right, + width: 20, + title: Some("Notes".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Write a note".to_string(), + rows: 2, + }), + ..PanelConfig::default() + }, + ); + "notes" + }; + assert!(harness.editor.test_focus_panel(id)); + let before = harness.editor.test_session_snapshot().window_layout; + + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::CONTROL, + ))) + .await + .unwrap(); + assert!(harness.is_waiting_for_key_sequence()); + harness + .execute_event(Event::Key(KeyEvent::new( + KeyCode::Char('H'), + KeyModifiers::SHIFT, + ))) + .await + .unwrap(); + + let after = harness.editor.test_session_snapshot().window_layout; + assert_ne!( + after.root, before.root, + "Ctrl-w H must retain upstream split movement for the {id} panel" + ); + assert!(matches!( + after.root, + SplitSnapshot::Vertical { left, .. } + if matches!(left.as_ref(), SplitSnapshot::Window { .. }) + )); + assert_eq!(harness.window_count(), 3); + assert_eq!(harness.editor.test_focused_panel_id(), Some(id)); + assert_eq!(harness.editor.test_agent_position(), AgentPosition::Right); + assert!(!harness.is_waiting_for_key_sequence()); + } +} + #[tokio::test] async fn lowercase_window_chords_preserve_split_topology_and_move_focus() { for (key, preparation) in [ @@ -5482,7 +6112,9 @@ async fn focused_panel_routes_ctrl_w_w_into_focus_cycle() { #[tokio::test] async fn ctrl_w_w_focuses_agent_composer_and_makes_cursor_visible() { let buffer = Buffer::new(None, "abcdef".to_string()); - let mut harness = EditorHarness::with_config(buffer, default_key_config()); + let mut config = default_key_config(); + config.cursor.normal = CursorShape::SteadyBlock; + let mut harness = EditorHarness::with_config(buffer, config); harness.editor.test_create_text_panel( "agent", PanelConfig { @@ -5541,6 +6173,25 @@ async fn ctrl_w_w_focuses_agent_composer_and_makes_cursor_visible() { .editor .test_handle_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) .unwrap(); + assert!(matches!( + action, + Some(KeyAction::Multiple(actions)) + if actions.iter().any(|action| matches!( + action, + Action::NotifyPlugins(name, payload) + if name == "panel:event:agent" && payload["action"] == "composer_input" + )) + )); + assert_eq!(harness.editor.test_focused_panel_id(), Some("agent")); + assert!(harness.render_cursor_position().is_some()); + + let action = harness + .editor + .test_handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))) + .unwrap(); assert!(matches!( action, Some(KeyAction::Multiple(actions)) @@ -5551,7 +6202,226 @@ async fn ctrl_w_w_focuses_agent_composer_and_makes_cursor_visible() { )) )); assert_eq!(harness.editor.test_focused_panel_id(), Some("agent")); - assert_eq!(harness.render_cursor_position(), None); + assert!(harness.render_cursor_position().is_some()); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBlock + ); +} + +#[test] +fn focused_agent_transcript_i_and_a_request_composer_in_every_dock_position() { + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + for key in ['i', 'a'] { + let mut config = default_key_config(); + config.cursor.normal = CursorShape::SteadyBlock; + config.cursor.insert = CursorShape::BlinkingBar; + let mut harness = EditorHarness::with_config( + Buffer::new(None, "background editor".to_string()), + config, + ); + harness.editor.test_create_text_panel( + "agent", + PanelConfig { + side, + width: match side { + PanelSide::Left | PanelSide::Right => 30, + PanelSide::Top | PanelSide::Bottom => 8, + }, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask".to_string(), + rows: 2, + }), + surface: None, + border: None, + header_actions: Vec::new(), + }, + ); + + assert!(harness.editor.test_focus_panel("agent")); + let (x, y) = harness + .render_cursor_position() + .expect("focused conversation must expose a cursor"); + assert!(x < 80 && y < 24, "cursor must stay on screen"); + assert!( + match side { + PanelSide::Left => x < 30, + PanelSide::Right => x >= 50, + PanelSide::Top => y < 8, + PanelSide::Bottom => y >= 14, + }, + "conversation cursor ({x}, {y}) must be inside the {side:?} dock" + ); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBlock, + "conversation must use the Normal cursor in the {side:?} dock" + ); + + let action = harness + .editor + .test_handle_event(Event::Key(KeyEvent::new( + KeyCode::Char(key), + KeyModifiers::NONE, + ))) + .unwrap(); + + assert!( + matches!( + action, + Some(KeyAction::Multiple(actions)) + if actions.iter().any(|action| matches!( + action, + Action::NotifyPlugins(name, payload) + if name == "panel:event:agent" + && payload["action"] == "composer_focus" + )) + ), + "{key} must request composer focus from the {side:?} conversation" + ); + assert_eq!(harness.editor.test_focused_panel_id(), Some("agent")); + assert_eq!(harness.buffer_contents(), "background editor"); + } + } +} + +#[test] +fn agent_composer_uses_its_configured_mode_cursor_in_every_dock_position() { + for side in [ + PanelSide::Left, + PanelSide::Right, + PanelSide::Top, + PanelSide::Bottom, + ] { + let mut config = default_key_config(); + config.cursor.normal = CursorShape::SteadyBlock; + config.cursor.insert = CursorShape::BlinkingBar; + config.cursor.visual = CursorShape::BlinkingUnderscore; + let buffer = Buffer::new(None, "background editor".to_string()); + let mut harness = EditorHarness::with_config(buffer, config); + harness.editor.test_create_text_panel( + "agent", + PanelConfig { + side, + width: match side { + PanelSide::Left | PanelSide::Right => 30, + PanelSide::Top | PanelSide::Bottom => 8, + }, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask".to_string(), + rows: 2, + }), + surface: None, + border: None, + header_actions: Vec::new(), + }, + ); + let editor_cursor = harness.render_cursor_position(); + + assert_eq!(harness.mode(), Mode::Normal, "dock side: {side:?}"); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBlock, + "dock side: {side:?}" + ); + assert!(harness.editor.test_focus_panel("agent")); + let transcript_cursor = harness.render_cursor_position(); + assert!( + transcript_cursor.is_some(), + "focused conversation must expose its Normal cursor for {side:?} dock" + ); + assert_ne!( + transcript_cursor, editor_cursor, + "focused conversation must move the cursor into the {side:?} dock" + ); + assert!(harness.editor.test_focus_text_panel_composer("agent")); + assert_eq!(harness.mode(), Mode::Normal, "dock side: {side:?}"); + assert!(harness.render_cursor_position().is_some()); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::BlinkingBar, + "insert cursor for {side:?} dock" + ); + + harness + .editor + .test_handle_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBlock, + "normal cursor for {side:?} dock" + ); + + harness + .editor + .test_handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))) + .unwrap(); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::BlinkingUnderscore, + "visual cursor for {side:?} dock" + ); + + harness + .editor + .test_handle_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(); + harness + .editor + .test_handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::BlinkingBar, + "restored insert cursor for {side:?} dock" + ); + + harness + .editor + .test_handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))) + .unwrap(); + assert_eq!( + harness.editor.test_active_cursor_shape(), + CursorShape::SteadyBlock, + "conversation cursor for {side:?} dock" + ); + assert!( + harness.render_cursor_position().is_some(), + "focused conversation must retain a visible cursor for {side:?} dock" + ); + + harness + .editor + .test_handle_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(); + assert_eq!( + harness.editor.test_focused_panel_id(), + None, + "Escape must return focus to the editor from the {side:?} conversation" + ); + assert_eq!( + harness.render_cursor_position(), + editor_cursor, + "Escape must restore the background editor cursor for {side:?} dock" + ); + } } #[tokio::test]