diff --git a/README.md b/README.md index 2d669e3e..61264bfd 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ The bundled plugins: To turn one off, add its plugin ID to `disabled_plugins` in your config, e.g. `disabled_plugins = ["fidget"]`. Core-owned session recovery, including dirty buffers and undo history, is documented in [`docs/SESSION_RECOVERY.md`](docs/SESSION_RECOVERY.md). -Setting `disable_ai = true` removes the agent plugin and prevents Codex startup. Red launches an installed, authenticated `codex` CLI directly as an app-server. Press `Space A` from normal or visual mode (or run `:Agent`) for the first prompt, then use the persistent conversation footer for follow-ups: click it to position the cursor, cycle into it with `Ctrl-w w`, or press `a`, type, and press Enter to send; `Ctrl-j` or Shift-Enter inserts a newline, Escape leaves the footer, and `Ctrl-p` / `Ctrl-n` recalls prompt history. The pane header provides clickable `Clear`, `New`, and `×` controls. Follow-ups submitted while a turn is running queue in order, `Ctrl-c` stops safely, `x`/`:AgentClear` clears only the visible conversation while preserving context and the current draft, `N`/`:AgentNew` resets the session and starts a new conversation, `q`/`:AgentClose` hides the pane without losing the session or draft, and `y`/`Y` copies the last answer/all messages. Running `:Agent` or `:AgentPrompt` restores a hidden pane. Prompts up to 128 KiB are supported; an oversized paste preserves the current draft and shows a validation message. +Setting `disable_ai = true` removes the agent plugin and prevents Codex startup. Red launches an installed, authenticated `codex` CLI directly as an app-server. Press `Space A` from normal or visual mode (or run `:Agent`) for the first prompt, then use the persistent conversation footer for follow-ups: click it to position the cursor, cycle into it with `Ctrl-w w`, or press `a`, type, and press Enter to send; `Ctrl-j` or Shift-Enter inserts a newline, Escape leaves the footer, and `Ctrl-p` / `Ctrl-n` recalls prompt history. Use `Ctrl-w a` or `:AgentOpen` to show and focus the pane without opening a prompt or starting a session. The pane header provides clickable `Clear`, `New`, and `×` controls. Follow-ups render immediately with a busy indicator; submissions made during an active turn remain visible and queue in order. `Ctrl-c` stops safely, `x`/`:AgentClear` clears only the visible conversation while preserving context and the current draft, `N`/`:AgentNew` resets the session and starts a new conversation, `q`/`:AgentClose` hides the pane without losing the session or draft, and `y`/`Y` copies the last answer/all messages. Running `:Agent` or `:AgentPrompt` also restores a hidden pane while opening the prompt composer. Prompts up to 128 KiB are supported; an oversized paste preserves the current draft and shows a validation message. Each turn includes a bounded active-file selection or cursor excerpt with unsaved contents and relevant diagnostics. Ignored, out-of-workspace, binary, and common secret/credential files are omitted. Codex can inspect editor state, open files and splits, select text, use safe navigation/LSP actions, and stage atomic UTF-16 range edits as proposals; tool activity appears in the conversation footer and existing composer focus is preserved. Conversation and prompt history are scoped to the workspace. Red reports the pending file/hunk count when changes are ready and keeps them reviewable with `:AgentReview`. If setup fails, the prompt is preserved for retry after installing Codex or running `codex login`. The offline `red --agent-check` prerequisite report is also available (`red --agent-check --strict` exits non-zero when not ready). Red does not fall back to `codex exec` or native workspace edits. diff --git a/default_config.toml b/default_config.toml index 7010e39e..ef233b15 100644 --- a/default_config.toml +++ b/default_config.toml @@ -218,6 +218,7 @@ Esc = { EnterMode = "Normal" } "h" = { PluginCommand = "GitHunkNext" } [keys.normal."Ctrl-w"] +"a" = { PluginCommand = "AgentOpen" } "h" = "MoveWindowLeft" "j" = "MoveWindowDown" "k" = "MoveWindowUp" diff --git a/docs/AGENT_WORKFLOW.md b/docs/AGENT_WORKFLOW.md index b238a59a..233f9ff7 100644 --- a/docs/AGENT_WORKFLOW.md +++ b/docs/AGENT_WORKFLOW.md @@ -31,7 +31,9 @@ command = "/path/to/codex" 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`. Assistant deltas stream into the conversation footer. `Ctrl-c` +`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`. If Codex cannot start, Red preserves the prompt and offers a retry action. @@ -103,6 +105,7 @@ protocol is unavailable; it does not fall back to `codex exec` or native edits. | Command | Purpose | | --- | --- | | `:Agent` / `:AgentPrompt` | Open the prompt composer. | +| `:AgentOpen` | Show and focus the conversation pane without opening a prompt. | | `: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/plugins/agent.hk b/plugins/agent.hk index 72926cde..3ebd6794 100644 --- a/plugins/agent.hk +++ b/plugins/agent.hk @@ -17,6 +17,12 @@ pub fn activate() { description: "Ask a question in the active agent session", aliases: ["ask ai", "chat"], }); + red::add_command("AgentOpen", open_conversation, Json { + title: "Open agent conversation", + category: "Agent", + description: "Show and focus the conversation pane without opening a prompt", + aliases: ["show chat", "open chat"], + }); red::add_command("AgentCancel", cancel, Json { title: "Cancel agent request", category: "Agent", @@ -81,11 +87,13 @@ pub fn activate() { red::state_set("agent_session_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); red::state_set("agent_review_open", false); red::state_set("agent_detail", [[PanelSegment { text: "Select a hunk to inspect it", style: muted_style() }]]); red::state_set("agent_permission_request_id", ""); red::state_set("agent_prompt_history", []); red::state_set("agent_pending_prompt", ""); + red::state_set("agent_pending_prompt_block_id", ""); red::state_set("agent_pending_prompt_is_replay", false); red::state_set("agent_proposal_notice", false); red::state_set("agent_transcript", ""); @@ -93,6 +101,7 @@ pub fn activate() { red::state_set("agent_streaming", false); red::state_set("agent_turn_active", false); red::state_set("agent_prompt_queue", []); + red::state_set("agent_queued_prompt_blocks", []); red::state_set("agent_recap_pending", false); red::state_set("agent_cancelled_session_id", ""); red::state_set("agent_stream_block_id", ""); @@ -102,6 +111,11 @@ pub fn activate() { red::state_set("agent_stream_trimmed", false); red::state_set("agent_history_open", false); red::state_set("agent_history_detail", [[PanelSegment { text: "Select a transaction", style: muted_style() }]]); + red::state_set("agent_ui_phase", "idle"); + red::state_set("agent_ui_label", ""); + red::state_set("agent_activity_rows", []); + red::state_set("agent_activity_block_id", ""); + red::state_set("agent_thought", ""); } fn start() { @@ -115,6 +129,7 @@ fn cwd_loaded(result: Json) { fn session_created(event: Json) { let previous_session_id = red::string(red::state("agent_session_id"), ""); let pending = red::string(red::state("agent_pending_prompt"), ""); + let pending_block_id = red::string(red::state("agent_pending_prompt_block_id"), ""); let replay = red::state_bool("agent_pending_prompt_is_replay"); red::state_set("agent_session_id", event.session_id); if previous_session_id != event.session_id || (pending != "" && replay) { @@ -132,12 +147,13 @@ fn session_created(event: Json) { refresh_proposals(); if pending != "" { red::state_set("agent_pending_prompt", ""); + red::state_set("agent_pending_prompt_block_id", ""); red::state_set("agent_pending_prompt_is_replay", false); if replay { red::state_set("agent_turn_active", true); red::execute("AgentPrompt", event.session_id, pending); } else { - submit_prompt(pending); + dispatch_prompt_block(pending, pending_block_id); } } } @@ -149,11 +165,20 @@ fn prompt() { red::request("GetStorage", prompt_history_loaded, "prompt_history"); } +fn open_conversation() { + let was_created = red::state_bool("agent_panel_created"); + ensure_conversation_panel(); + if !was_created { + render_conversation(); + } + red::execute("FocusPanel", "agent-conversation"); +} + fn ensure_conversation_panel() { if !red::state_bool("agent_panel_created") { red::execute("CreateTextPanel", "agent-conversation", PanelConfig { side: "right", - width: 52, + width: 62, title: "Agent", composer: Json { placeholder: "Ask a follow-up…", rows: 3 }, header_actions: [ @@ -164,6 +189,7 @@ fn ensure_conversation_panel() { }); red::state_set("agent_panel_created", true); red::state_set("agent_panel_open", true); + red::state_set("agent_focus_panel_on_first_prompt", true); return; } if !red::state_bool("agent_panel_open") { @@ -198,15 +224,17 @@ fn prompt_submitted(prompt: Json) { } } red::state_set("agent_prompt_history", history); - red::execute("SetStorage", "prompt_history", history); red::state_set("agent_proposal_notice", false); if red::state("agent_session_id") == "" { + red::execute("SetStorage", "prompt_history", history); red::state_set("agent_pending_prompt", text); + red::state_set("agent_pending_prompt_block_id", ""); red::state_set("agent_pending_prompt_is_replay", false); start(); return; } submit_prompt(text); + red::execute("SetStorage", "prompt_history", history); } fn submit_prompt(text: String) { @@ -219,14 +247,34 @@ fn submit_prompt(text: String) { red::execute("Print", "Agent follow-up queue is full"); return; } - queue = red::push(queue, text); + let generation = red::int(red::state("agent_block_generation"), 0) + 1; + let block_id = "user:" + generation; + red::state_set("agent_block_generation", generation); + queue = red::push(queue, Json { text: text, block_id: block_id }); red::state_set("agent_prompt_queue", queue); - red::execute("SetTextPanelComposerState", "agent-conversation", true, "Working · " + red::len(queue) + " queued · Enter queues · ^C stops"); + // Pending blocks render below the active stream but do not enter the + // committed transcript until their FIFO turn is dispatched. + let queued_blocks = red::state("agent_queued_prompt_blocks"); + queued_blocks = red::push(queued_blocks, TextPanelBlock { + id: block_id, + kind: "user", + format: "plain", + text: text, + }); + let bounded = bounded_transcript_blocks(queued_blocks, block_id); + red::state_set("agent_queued_prompt_blocks", bounded.blocks); + if red::state_bool("agent_streaming") { + red::state_set("agent_stream_trimmed", true); + } + render_conversation(); + render_status(); + red::execute("Refresh"); red::execute("Print", "Agent turn is still running; follow-up queued (" + red::len(queue) + ")"); return; } if red::string(red::state("agent_session_id"), "") == "" { red::state_set("agent_pending_prompt", text); + red::state_set("agent_pending_prompt_block_id", ""); red::state_set("agent_pending_prompt_is_replay", false); start(); return; @@ -235,7 +283,14 @@ fn submit_prompt(text: String) { } fn dispatch_prompt(text: String) { + dispatch_prompt_block(text, ""); +} + +/// Promote an optional optimistic queue block, paint the busy state, and only +/// then persist and hand the turn to the agent bridge. +fn dispatch_prompt_block(text: String, block_id: String) { let wire_text = text; + let focus_conversation = red::state_bool("agent_focus_panel_on_first_prompt"); if red::state_bool("agent_recap_pending") { let recap = red::string(red::state("agent_transcript"), ""); if red::len(recap) > 6000 { @@ -246,21 +301,67 @@ fn dispatch_prompt(text: String) { } red::state_set("agent_recap_pending", false); } + remove_queued_prompt_block(block_id); red::state_set("agent_turn_active", true); - append_transcript("You", text); + append_transcript_block("You", text, block_id); + if focus_conversation { + red::state_set("agent_focus_panel_on_first_prompt", false); + red::execute("FocusPanel", "agent-conversation"); + } + set_phase("waiting", "Waiting for agent…"); + red::execute("Refresh"); + red::execute("SetStorage", "transcript", red::state("agent_transcript")); red::execute("AgentPrompt", red::state("agent_session_id"), wire_text); } +fn remove_queued_prompt_block(block_id: String) { + if block_id == "" { + return; + } + let queued_blocks = []; + for block in red::state("agent_queued_prompt_blocks") { + if block.id != block_id { + queued_blocks = red::push(queued_blocks, block); + } + } + red::state_set("agent_queued_prompt_blocks", queued_blocks); +} + fn turn_started(event: Json) { if !is_current_session_event(event) { return; } + set_phase("waiting", "Waiting for agent…"); +} + +/// Update the turn phase/label and re-render the panel status row. +fn set_phase(phase: String, label: String) { + red::state_set("agent_ui_phase", phase); + red::state_set("agent_ui_label", label); + render_status(); +} + +/// Single writer for the panel status row; every phase change funnels here so +/// queue depth and streaming state are never clobbered by individual events. +fn render_status() { + let phase = red::string(red::state("agent_ui_phase"), "idle"); + if phase == "idle" { + red::execute("SetTextPanelStatus", "agent-conversation"); + return; + } + let label = red::string(red::state("agent_ui_label"), ""); + if label == "" { + label = "Working…"; + } let queued = red::len(red::state("agent_prompt_queue")); - let status = "Working · Enter queues · ^C stops"; if queued > 0 { - status = "Working · " + queued + " queued · Enter queues · ^C stops"; + label = label + " · " + queued + " queued"; } - red::execute("SetTextPanelComposerState", "agent-conversation", true, status); + red::execute("SetTextPanelStatus", "agent-conversation", TextPanelStatus { + busy: true, + label: label, + stream: red::state_bool("agent_streaming"), + }); } fn activity(event: Json) { @@ -268,14 +369,198 @@ fn activity(event: Json) { return; } let update = event.update; - let label = red::string(update.title, ""); - if label == "" { - label = red::string(update.status, ""); + let kind = red::string(update.session_update, ""); + if kind == "agent_thought_chunk" { + if update.content != red::null() { + let text = red::string(update.content.text, ""); + if text != "" { + let thought = red::string(red::state("agent_thought"), "") + text; + if red::len(thought) > 240 { + thought = red::slice(thought, red::len(thought) - 240); + } + red::state_set("agent_thought", thought); + render_activity(); + } + } + set_phase("thinking", "Thinking…"); + } else if kind == "tool_call" { + let title = red::string(update.title, ""); + if title == "" { + title = red::string(update.kind, "Tool"); + } + let rows = red::state("agent_activity_rows"); + rows = red::push(rows, Json { + id: red::string(update.tool_call_id, ""), + title: title, + status: red::string(update.status, "in_progress"), + }); + red::state_set("agent_activity_rows", rows); + render_activity(); + set_phase("tool", title); + } else if kind == "tool_call_update" { + let target = red::string(update.tool_call_id, ""); + let status = red::string(update.status, ""); + let title = red::string(update.title, ""); + let rows = red::state("agent_activity_rows"); + let updated = []; + let active = ""; + for row in rows { + if red::string(row.id, "") == target && red::string(row.id, "") != "" { + if status != "" { + row.status = status; + } + if title != "" { + row.title = title; + } + } + let row_status = red::string(row.status, ""); + if row_status == "in_progress" || row_status == "pending" { + active = red::string(row.title, ""); + } + updated = red::push(updated, row); + } + red::state_set("agent_activity_rows", updated); + render_activity(); + if active != "" { + set_phase("tool", active); + } else if red::state_bool("agent_streaming") { + set_phase("streaming", "Writing…"); + } else { + set_phase("working", "Working…"); + } + } else if kind == "plan" { + set_phase("working", "Planning…"); } - if label == "" { - label = red::string(update.session_update, "Working"); +} + +/// Lazily create the current turn's activity timeline block. +fn ensure_activity_block() -> String { + let block_id = red::string(red::state("agent_activity_block_id"), ""); + if block_id != "" { + return block_id; + } + let generation = red::int(red::state("agent_block_generation"), 0) + 1; + red::state_set("agent_block_generation", generation); + block_id = "activity:" + generation; + red::state_set("agent_activity_block_id", block_id); + let blocks = red::state("agent_transcript_blocks"); + blocks = red::push(blocks, TextPanelBlock { + id: block_id, + kind: "activity", + format: "plain", + text: "", + }); + red::state_set("agent_transcript_blocks", blocks); + return block_id; +} + +fn activity_glyph(status: String) -> String { + if status == "completed" { + return "✓"; + } + if status == "failed" { + return "✗"; } - red::execute("SetTextPanelComposerState", "agent-conversation", true, label + " · Enter queues · ^C stops"); + return "→"; +} + +/// Re-render the in-conversation timeline: latest thought plus one line per tool. +fn render_activity() { + let block_id = ensure_activity_block(); + let text = ""; + let thought = red::trim(red::string(red::state("agent_thought"), "")); + if thought != "" { + text = "Thinking: " + thought; + } + for row in red::state("agent_activity_rows") { + if text != "" { + text = text + "\n"; + } + text = text + activity_glyph(red::string(row.status, "")) + " " + red::string(row.title, ""); + } + let blocks = red::state("agent_transcript_blocks"); + let updated = []; + for block in blocks { + if block.id == block_id { + block.text = text; + } + updated = red::push(updated, block); + } + red::state_set("agent_transcript_blocks", updated); + render_conversation(); +} + +/// Collapse the finished turn's timeline into a one-line summary (or drop it). +fn collapse_activity(elapsed_ms: Json) { + let block_id = red::string(red::state("agent_activity_block_id"), ""); + red::state_set("agent_activity_block_id", ""); + let rows = red::state("agent_activity_rows"); + red::state_set("agent_activity_rows", []); + red::state_set("agent_thought", ""); + let elapsed = format_turn_elapsed(elapsed_ms); + if block_id == "" && elapsed == "" { + return; + } + let count = red::len(rows); + let blocks = red::state("agent_transcript_blocks"); + if block_id == "" { + let generation = red::int(red::state("agent_block_generation"), 0) + 1; + red::state_set("agent_block_generation", generation); + block_id = "activity:" + generation; + blocks = red::push(blocks, TextPanelBlock { + id: block_id, + kind: "activity", + format: "plain", + text: "", + }); + } + let updated = []; + for block in blocks { + if block.id == block_id { + let summary = ""; + if count > 0 { + summary = "✓ " + count + " steps"; + if count == 1 { + summary = "✓ 1 step"; + } + } + if elapsed != "" { + if summary != "" { + summary = summary + "\n"; + } + summary = summary + "Worked for " + elapsed; + } + if summary == "" { + continue; + } + block.text = summary; + } + updated = red::push(updated, block); + } + red::state_set("agent_transcript_blocks", updated); + render_conversation(); +} + +fn format_turn_elapsed(elapsed_ms: Json) -> String { + let ms = red::int(elapsed_ms, 0); + if ms <= 0 { + return ""; + } + let seconds = ms / 1000; + if seconds < 1 { + return "<1s"; + } + if seconds < 60 { + return seconds + "s"; + } + let minutes = seconds / 60; + let remaining_seconds = seconds - minutes * 60; + if minutes < 60 { + return minutes + "m " + remaining_seconds + "s"; + } + let hours = minutes / 60; + let remaining_minutes = minutes - hours * 60; + return hours + "h " + remaining_minutes + "m " + remaining_seconds + "s"; } fn panel_event(event: Json) { @@ -302,6 +587,10 @@ fn panel_event(event: Json) { fn clear_conversation() { reset_stream_render(); red::state_set("agent_transcript_blocks", []); + red::state_set("agent_queued_prompt_blocks", []); + red::state_set("agent_activity_block_id", ""); + red::state_set("agent_activity_rows", []); + red::state_set("agent_thought", ""); red::execute("UpdateTextPanel", "agent-conversation", []); red::execute("SetTextPanelComposerState", "agent-conversation", true, "Conversation view cleared · context preserved"); } @@ -334,9 +623,11 @@ fn new_conversation() { red::state_set("agent_session_id", ""); red::state_set("agent_cancelled_session_id", ""); red::state_set("agent_turn_active", false); + set_phase("idle", ""); red::state_set("agent_prompt_queue", []); red::state_set("agent_recap_pending", false); red::state_set("agent_pending_prompt", ""); + red::state_set("agent_pending_prompt_block_id", ""); red::state_set("agent_pending_prompt_is_replay", false); red::state_set("agent_proposal_notice", false); clear_conversation(); @@ -387,6 +678,7 @@ fn update(event: Json) { }); red::state_set("agent_transcript_blocks", blocks); render_conversation(); + set_phase("streaming", "Writing…"); } transcript = transcript + delta; @@ -424,17 +716,18 @@ fn completed(event: Json) { red::state_set("agent_turn_active", false); let session_id = red::string(event.session_id, ""); let stop_reason = red::string(event.stop_reason, "end_turn"); - if stop_reason == "end_turn" { + if stop_reason == "end_turn" || stop_reason == "completed" { finish_agent_turn(); } + collapse_activity(event.elapsed_ms); + set_phase("idle", ""); if session_id != "" && (stop_reason == "cancelled" || session_id == red::string(red::state("agent_cancelled_session_id"), "")) { red::execute("AgentCloseSession", session_id); red::state_set("agent_session_id", ""); red::state_set("agent_cancelled_session_id", ""); red::state_set("agent_recap_pending", true); } - if stop_reason == "end_turn" { - red::execute("SetTextPanelComposerState", "agent-conversation", true, "Ready · Enter sends · ^J adds a line"); + if stop_reason == "end_turn" || stop_reason == "completed" { submit_next_queued(); return; } @@ -458,14 +751,16 @@ fn submit_next_queued() { index = index + 1; } red::state_set("agent_prompt_queue", remaining); - let text = red::string(next, ""); + let text = red::string(next.text, red::string(next, "")); + let block_id = red::string(next.block_id, ""); if red::string(red::state("agent_session_id"), "") == "" { red::state_set("agent_pending_prompt", text); + red::state_set("agent_pending_prompt_block_id", block_id); red::state_set("agent_pending_prompt_is_replay", false); start(); return; } - dispatch_prompt(text); + dispatch_prompt_block(text, block_id); } fn cancelled(event: Json) { @@ -483,7 +778,7 @@ fn cancelled(event: Json) { red::state_set("agent_recap_pending", true); } red::execute("Print", "Agent cancellation requested"); - red::execute("SetTextPanelComposerState", "agent-conversation", true, "Stopping · follow-ups remain queued"); + set_phase("stopping", "Stopping…"); } fn failed(event: Json) { @@ -495,6 +790,8 @@ fn failed(event: Json) { return; } red::state_set("agent_turn_active", false); + collapse_activity(red::null()); + set_phase("idle", ""); let session_id = red::string(event.session_id, ""); if session_id != "" && session_id == red::string(red::state("agent_cancelled_session_id"), "") { red::execute("AgentCloseSession", session_id); @@ -528,6 +825,8 @@ fn session_lost(event: Json) { return; } red::state_set("agent_turn_active", false); + collapse_activity(red::null()); + set_phase("idle", ""); if session_id != "" { red::execute("AgentArchiveSession", session_id); red::state_set("agent_recap_pending", true); @@ -536,6 +835,7 @@ fn session_lost(event: Json) { red::state_set("agent_cancelled_session_id", ""); if attempted != "" { red::state_set("agent_pending_prompt", attempted); + red::state_set("agent_pending_prompt_block_id", ""); red::state_set("agent_pending_prompt_is_replay", true); red::execute("Print", "Codex app-server stopped; retrying the saved prompt"); start(); @@ -587,6 +887,11 @@ fn finish_agent_turn() { } fn append_transcript(role: String, text: String) { + append_transcript_block(role, text, ""); + red::execute("SetStorage", "transcript", red::state("agent_transcript")); +} + +fn append_transcript_block(role: String, text: String, block_id: String) { let transcript = red::state("agent_transcript"); if red::state_bool("agent_streaming") { if !red::ends_with(transcript, "\n") { @@ -607,19 +912,21 @@ fn append_transcript(role: String, text: String) { transcript = red::slice(transcript, red::len(transcript) - 20000); } red::state_set("agent_transcript", transcript); - red::execute("SetStorage", "transcript", transcript); - let generation = red::int(red::state("agent_block_generation"), 0) + 1; - red::state_set("agent_block_generation", generation); let kind = "text"; if role == "You" { kind = "user"; } else if role == "Error" { kind = "error"; } + if block_id == "" { + let generation = red::int(red::state("agent_block_generation"), 0) + 1; + red::state_set("agent_block_generation", generation); + block_id = kind + ":" + generation; + } let blocks = red::state("agent_transcript_blocks"); blocks = red::push(blocks, TextPanelBlock { - id: kind + ":" + generation, + id: block_id, kind: kind, format: "plain", text: text, @@ -667,12 +974,21 @@ fn transcript_restored(event: Json) { fn render_conversation() { let blocks = red::state("agent_transcript_blocks"); + let keep_id = red::string(red::state("agent_stream_block_id"), ""); + for block in red::state("agent_queued_prompt_blocks") { + blocks = red::push(blocks, block); + if keep_id == "" { + keep_id = block.id; + } + } + let bounded = bounded_transcript_blocks(blocks, keep_id); + blocks = bounded.blocks; if red::len(blocks) == 0 { blocks = [TextPanelBlock { id: "empty", - kind: "text", + kind: "activity", format: "plain", - text: "No messages yet. Press a to focus the composer or Space A for a new prompt.", + 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", }]; } red::execute("UpdateTextPanel", "agent-conversation", blocks); @@ -1077,6 +1393,7 @@ fn deactivate() { if session_id != "" { red::execute("AgentCloseSession", session_id); } + red::execute("SetTextPanelStatus", "agent-conversation"); red::state_set("agent_session_id", ""); red::state_set("agent_cancelled_session_id", ""); red::state_set("agent_review_open", false); diff --git a/src/config.rs b/src/config.rs index f17154d7..8264776c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1287,6 +1287,21 @@ groups = [["\\bif\\b", "\\belse\\b", "\\bendif\\b"]] ); } + #[test] + fn default_config_maps_ctrl_w_a_to_agent_open() { + let config: Config = toml::from_str(include_str!("../default_config.toml")).unwrap(); + let Some(KeyAction::Nested(window_commands)) = config.keys.normal.get("Ctrl-w") else { + panic!("expected a Ctrl-w keymap"); + }; + + assert_eq!( + window_commands.get("a"), + Some(&KeyAction::Single(Action::PluginCommand( + "AgentOpen".to_string() + ))) + ); + } + #[test] fn default_config_enables_project_search() { let config: Config = toml::from_str(include_str!("../default_config.toml")).unwrap(); diff --git a/src/editor.rs b/src/editor.rs index 58197f6e..e83469fd 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -320,6 +320,42 @@ fn expanded_path_string(path: &str) -> anyhow::Result { Ok(expand_user_path(path)?.to_string_lossy().into_owned()) } +fn open_external_url(url: &str) -> std::io::Result { + #[cfg(target_os = "macos")] + { + return tokio::process::Command::new("open") + .arg("--") + .arg(url) + .spawn(); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + return tokio::process::Command::new("xdg-open").arg(url).spawn(); + } + #[cfg(windows)] + { + return tokio::process::Command::new("rundll32.exe") + .arg("url.dll,FileProtocolHandler") + .arg(url) + .spawn(); + } + #[allow(unreachable_code)] + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "opening links is not supported on this platform", + )) +} + +fn validate_text_panel_file(path: &str) -> anyhow::Result<()> { + let expanded = expanded_path_string(path)?; + let resolved = Path::new(&expanded).absolutize()?; + let metadata = std::fs::metadata(resolved.as_ref())?; + if !metadata.is_file() { + anyhow::bail!("Link target is not a file: {path}"); + } + Ok(()) +} + fn plugin_lsp_error(message: &str) -> Value { json!({ "ok": false, @@ -735,6 +771,10 @@ pub enum PluginRequest { enabled: bool, status: Option, }, + SetTextPanelStatus { + id: String, + status: Option, + }, ClearTextPanelComposer { id: String, }, @@ -867,6 +907,7 @@ impl PluginRequest { Self::AppendTextPanel { .. } => "AppendTextPanel", Self::FocusTextPanelComposer { .. } => "FocusTextPanelComposer", Self::SetTextPanelComposerState { .. } => "SetTextPanelComposerState", + Self::SetTextPanelStatus { .. } => "SetTextPanelStatus", Self::ClearTextPanelComposer { .. } => "ClearTextPanelComposer", Self::SelectPanelRow { .. } => "SelectPanelRow", Self::FocusPanel { .. } => "FocusPanel", @@ -1007,6 +1048,7 @@ pub enum Action { MoveTo(usize, usize), MoveToFilePos(String, usize, usize), OpenLocation(plugin::PluginLocation, plugin::OpenLocationTarget), + OpenExternalUrl(String), MoveToNextWord, MoveToPreviousWord, MoveToNextBigWord, @@ -1469,6 +1511,8 @@ pub struct Editor { agent_workspace: Option>>, agent_tool_requests: Option>, agent_active_sessions: HashSet, + /// Prompt dispatch times used to report per-turn elapsed durations. + agent_turn_started: HashMap, /// Core-owned crash recovery store. It is optional in tests and embedded uses. session_store: Option, @@ -2551,6 +2595,7 @@ impl Editor { agent_workspace: None, agent_tool_requests: None, agent_active_sessions: HashSet::new(), + agent_turn_started: HashMap::new(), session_store: None, last_session_snapshot: Instant::now(), last_session_snapshot_generation: None, @@ -4949,6 +4994,8 @@ impl Editor { ) .await?; self.agent_active_sessions.insert(session_id.clone()); + self.agent_turn_started + .insert(session_id.clone(), Instant::now()); let Some(bridge) = &self.agent_bridge else { return Ok(false); }; @@ -5413,6 +5460,7 @@ impl Editor { task.abort(); } self.agent_active_sessions.clear(); + self.agent_turn_started.clear(); self.agent_tool_requests = None; } @@ -5509,7 +5557,24 @@ impl Editor { if matches!(event, CodexEvent::ProposalsChanged { .. }) { continue; } - let (name, payload) = agent_event_payload(event); + let turn_elapsed_ms = match &event { + CodexEvent::Completed { session_id, .. } => self + .agent_turn_started + .remove(session_id) + .map(|started| started.elapsed().as_millis() as u64), + CodexEvent::Failed { + session_id: Some(session_id), + .. + } => { + self.agent_turn_started.remove(session_id); + None + } + _ => None, + }; + let (name, mut payload) = agent_event_payload(event); + if let (Some(elapsed_ms), Some(object)) = (turn_elapsed_ms, payload.as_object_mut()) { + object.insert("elapsed_ms".to_string(), json!(elapsed_ms)); + } self.plugin_registry.notify(runtime, name, payload).await?; } for session_id in proposal_sessions { @@ -5559,7 +5624,8 @@ impl Editor { self.keymap_hint_deadline = None; self.keymap_hints_visible = true; } - if dialog_changed || keymap_hints_changed { + let panel_animation_changed = self.panel_manager.poll_animation(); + if dialog_changed || keymap_hints_changed || panel_animation_changed { self.render(buffer)?; } @@ -6787,6 +6853,11 @@ impl Editor { needs_render = true; } } + PluginRequest::SetTextPanelStatus { id, status } => { + if self.panel_manager.set_text_panel_status(&id, status) { + needs_render = true; + } + } PluginRequest::ClearTextPanelComposer { id } => { if self.panel_manager.clear_text_panel_composer(&id) { needs_render = true; @@ -8828,6 +8899,25 @@ impl Editor { return Some(KeyAction::Single(Action::Refresh)); } } + if matches!(event.code, KeyCode::Tab | KeyCode::BackTab) { + let panel_height = usize::from(self.size.1.saturating_sub(2)); + let forward = event.code == KeyCode::Tab; + if self.panel_manager.select_focused_text_link( + forward, + panel_height, + usize::from(self.size.0), + ) { + return Some(KeyAction::Single(Action::Refresh)); + } + } + if event.code == KeyCode::Enter { + if let Some(target) = self + .panel_manager + .focused_text_link_target(usize::from(self.size.0)) + { + return Some(self.follow_text_panel_link(target)); + } + } let action = match event.code { KeyCode::Esc => { self.panel_manager.focus_editor(); @@ -8878,10 +8968,17 @@ impl Editor { let height = self.size.1 as usize; match event.kind { - MouseEventKind::Down(MouseButton::Left) => self - .panel_manager - .focus_panel_at_position(x, y, width, height) - .and_then(Self::panel_event_key_action), + MouseEventKind::Down(MouseButton::Left) => { + if let Some(target) = self + .panel_manager + .text_link_at_position(x, y, width, height) + { + return Some(self.follow_text_panel_link(target)); + } + self.panel_manager + .focus_panel_at_position(x, y, width, height) + .and_then(Self::panel_event_key_action) + } MouseEventKind::ScrollUp => { let id = self .panel_manager @@ -8915,6 +9012,48 @@ impl Editor { }) } + fn follow_text_panel_link(&mut self, target: plugin::TextPanelLinkTarget) -> KeyAction { + match target { + plugin::TextPanelLinkTarget::File { path, location } => { + if let Err(error) = validate_text_panel_file(&path) { + self.last_error = Some(format!("Unable to open link: {error}")); + return KeyAction::Single(Action::Refresh); + } + self.panel_manager.focus_editor(); + if let Some(location) = location { + KeyAction::Single(Action::OpenLocation( + plugin::PluginLocation { + path, + line: location.line.saturating_sub(1), + column: location.column.saturating_sub(1), + column_encoding: plugin::LocationColumnEncoding::Utf8Byte, + }, + plugin::OpenLocationTarget::Current, + )) + } else { + let path = self.open_buffer_name_for_path(&path).unwrap_or(path); + KeyAction::Single(Action::OpenFile(path)) + } + } + plugin::TextPanelLinkTarget::ExternalUrl(url) => { + KeyAction::Single(Action::OpenExternalUrl(url)) + } + } + } + + fn open_buffer_name_for_path(&self, path: &str) -> Option { + let expanded = expanded_path_string(path).ok()?; + let absolute = Path::new(&expanded).absolutize().ok()?; + self.buffers + .iter() + .find(|buffer| { + Path::new(buffer.name()) + .absolutize() + .is_ok_and(|candidate| candidate == absolute) + }) + .map(|buffer| buffer.name().to_string()) + } + fn panel_global_key_action(&self, ev: &event::Event) -> Option { let key = Self::key_string_for_event(ev)?; let action = self @@ -13104,6 +13243,24 @@ impl Editor { self.execute_with_tracking(&Action::MoveTo(*x, *y), buffer, runtime, false) .await?; } + Action::OpenExternalUrl(url) => { + let lowercase = url.to_ascii_lowercase(); + if !lowercase.starts_with("https://") && !lowercase.starts_with("http://") { + self.last_error = Some("Only HTTP and HTTPS links can be opened".to_string()); + return Ok(false); + } + match open_external_url(url) { + Ok(mut child) => { + tokio::spawn(async move { + let _ = child.wait().await; + }); + } + Err(error) => { + self.last_error = Some(format!("Unable to open link: {error}")); + return Ok(false); + } + } + } Action::OpenLocation(location, target) => { let path = match expanded_path_string(&location.path).and_then(|path| { Ok(Path::new(&path) @@ -24809,6 +24966,96 @@ while True: std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn text_panel_file_links_require_an_existing_file() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("linked.rs"); + std::fs::write(&file, "fn linked() {}\n").unwrap(); + + assert!(validate_text_panel_file(file.to_str().unwrap()).is_ok()); + assert!(validate_text_panel_file(directory.path().to_str().unwrap()).is_err()); + assert!( + validate_text_panel_file(directory.path().join("missing.rs").to_str().unwrap()) + .is_err() + ); + } + + #[tokio::test] + async fn locationless_text_panel_links_preserve_existing_buffer_positions() { + let directory = tempfile::tempdir().unwrap(); + let existing_path = directory.path().join("existing.rs"); + let new_path = directory.path().join("new.rs"); + std::fs::write(&existing_path, "zero\none two\nthree\n").unwrap(); + std::fs::write(&new_path, "new\nfile\n").unwrap(); + + let mut existing = Buffer::new( + Some(existing_path.to_string_lossy().into_owned()), + "zero\none two\nthree\n".to_string(), + ); + existing.pos = (4, 1); + let config = Config::default(); + let lsp = Box::new(crate::lsp::LspManager::new(config.lsp.clone())); + let mut editor = Editor::with_size( + lsp, + 40, + 10, + config, + Theme::default(), + vec![Buffer::new(None, "start\n".to_string()), existing], + ) + .unwrap(); + editor.test_disable_terminal_output(); + let mut render_buffer = RenderBuffer::new(40, 10, &Style::default()); + let mut runtime = Runtime::new(); + + let bare = editor.follow_text_panel_link(plugin::TextPanelLinkTarget::File { + path: existing_path.to_string_lossy().into_owned(), + location: None, + }); + let KeyAction::Single(bare) = bare else { + panic!("bare file link should produce one action"); + }; + editor + .execute(&bare, &mut render_buffer, &mut runtime) + .await + .unwrap(); + assert_eq!((editor.cx, editor.cy), (4, 1)); + + editor.cx = 1; + editor.cy = 2; + editor + .execute(&bare, &mut render_buffer, &mut runtime) + .await + .unwrap(); + assert_eq!((editor.cx, editor.cy), (1, 2)); + + let explicit = editor.follow_text_panel_link(plugin::TextPanelLinkTarget::File { + path: existing_path.to_string_lossy().into_owned(), + location: Some(plugin::TextPanelFileLocation { line: 1, column: 1 }), + }); + let KeyAction::Single(explicit) = explicit else { + panic!("located file link should produce one action"); + }; + editor + .execute(&explicit, &mut render_buffer, &mut runtime) + .await + .unwrap(); + assert_eq!((editor.cx, editor.cy), (0, 0)); + + let unopened = editor.follow_text_panel_link(plugin::TextPanelLinkTarget::File { + path: new_path.to_string_lossy().into_owned(), + location: None, + }); + let KeyAction::Single(unopened) = unopened else { + panic!("unopened file link should produce one action"); + }; + editor + .execute(&unopened, &mut render_buffer, &mut runtime) + .await + .unwrap(); + assert_eq!((editor.cx, editor.cy), (0, 0)); + } + #[tokio::test] async fn open_location_converts_utf8_bytes_and_reuses_buffers_for_splits() { let file = diff --git a/src/plugin/host_api.json b/src/plugin/host_api.json index d20f7208..184a3aa3 100644 --- a/src/plugin/host_api.json +++ b/src/plugin/host_api.json @@ -57,6 +57,7 @@ { "name": "AppendTextPanel", "kind": "execute", "signature": "(id: String, block_id: String, delta: String)", "introduced": "0.2.0" }, { "name": "FocusTextPanelComposer", "kind": "execute", "signature": "(id: String)", "introduced": "0.2.0" }, { "name": "SetTextPanelComposerState", "kind": "execute", "signature": "(id: String, enabled: bool, status?: String)", "introduced": "0.2.0" }, + { "name": "SetTextPanelStatus", "kind": "execute", "signature": "(id: String, status?: TextPanelStatus)", "introduced": "0.2.0" }, { "name": "ClearTextPanelComposer", "kind": "execute", "signature": "(id: String)", "introduced": "0.2.0" }, { "name": "SelectPanelRow", "kind": "execute", "signature": "(id: String, row_id: String)", "introduced": "0.1.0" }, { "name": "FocusPanel", "kind": "execute", "signature": "(id: String)", "introduced": "0.1.0" }, diff --git a/src/plugin/markdown.rs b/src/plugin/markdown.rs index 41447ad3..fb5510b0 100644 --- a/src/plugin/markdown.rs +++ b/src/plugin/markdown.rs @@ -3,6 +3,11 @@ use pulldown_cmark::{Alignment, CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; use unicode_segmentation::UnicodeSegmentation; +#[cfg(test)] +use super::text_link::TextPanelFileLocation; +use super::text_link::{ + linkify_source_locations, markdown_link_target, TextPanelLink, TextPanelLinkTarget, +}; use crate::unicode_utils::display_width; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -26,6 +31,7 @@ pub(super) enum TextPanelSpanStyle { pub(super) struct RenderedTextSpan { pub(super) text: String, pub(super) style: TextPanelSpanStyle, + pub(super) link: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -36,7 +42,11 @@ pub(super) struct RenderedTextLine { impl RenderedTextLine { pub(super) fn plain(text: String, style: TextPanelSpanStyle) -> Self { Self { - spans: vec![RenderedTextSpan { text, style }], + spans: vec![RenderedTextSpan { + text, + style, + link: None, + }], } } @@ -116,6 +126,8 @@ struct MarkdownRenderer { lines: Vec, current: Vec, styles: Vec, + links: Vec>, + next_link_id: u64, lists: Vec, items: Vec, quote_depth: usize, @@ -130,6 +142,8 @@ impl MarkdownRenderer { lines: Vec::new(), current: Vec::new(), styles: vec![TextPanelSpanStyle::Agent], + links: Vec::new(), + next_link_id: 0, lists: Vec::new(), items: Vec::new(), quote_depth: 0, @@ -160,7 +174,7 @@ impl MarkdownRenderer { Event::End(tag) => self.end(tag), Event::Text(text) => self.append_text(&text), Event::Code(text) | Event::InlineMath(text) => { - self.append(&text, TextPanelSpanStyle::InlineCode); + self.append_linkified(&text, TextPanelSpanStyle::InlineCode); } Event::DisplayMath(text) => self.append(&text, TextPanelSpanStyle::Code), Event::Html(text) | Event::InlineHtml(text) => self.append_text(&text), @@ -197,7 +211,7 @@ impl MarkdownRenderer { Tag::Heading { .. } => { self.flush_current(); self.blank_line(); - self.append("◆ ", TextPanelSpanStyle::Heading); + self.append("▍ ", TextPanelSpanStyle::Heading); self.styles.push(TextPanelSpanStyle::Heading); } Tag::BlockQuote(_) => { @@ -228,6 +242,7 @@ impl MarkdownRenderer { let spans = vec![RenderedTextSpan { text: title, style: TextPanelSpanStyle::Muted, + link: None, }]; self.lines .extend(wrap_spans(&spans, self.width, &prefix, &continuation)); @@ -259,7 +274,12 @@ impl MarkdownRenderer { Tag::Emphasis => self.styles.push(TextPanelSpanStyle::Emphasis), Tag::Strong => self.styles.push(TextPanelSpanStyle::Strong), Tag::Strikethrough => self.styles.push(TextPanelSpanStyle::Strikethrough), - Tag::Link { .. } | Tag::Image { .. } => self.styles.push(TextPanelSpanStyle::Link), + Tag::Link { dest_url, .. } => { + self.styles.push(TextPanelSpanStyle::Link); + let link = markdown_link_target(&dest_url).map(|target| self.new_link(target)); + self.links.push(link); + } + Tag::Image { .. } => self.styles.push(TextPanelSpanStyle::Link), Tag::Table(alignments) => { self.flush_current(); self.blank_line(); @@ -321,6 +341,7 @@ impl MarkdownRenderer { let spans = vec![RenderedTextSpan { text: source_line.replace('\t', " "), style: TextPanelSpanStyle::Code, + link: None, }]; self.lines.extend(wrap_verbatim( &spans, @@ -334,6 +355,7 @@ impl MarkdownRenderer { &[RenderedTextSpan { text: "└─".to_string(), style: TextPanelSpanStyle::Muted, + link: None, }], self.width, &continuation, @@ -352,11 +374,11 @@ impl MarkdownRenderer { self.flush_current(); self.items.pop(); } - TagEnd::Emphasis - | TagEnd::Strong - | TagEnd::Strikethrough - | TagEnd::Link - | TagEnd::Image => { + TagEnd::Link => { + self.styles.pop(); + self.links.pop(); + } + TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Image => { self.styles.pop(); } TagEnd::TableCell => { @@ -400,20 +422,54 @@ impl MarkdownRenderer { code.push_str(text); return; } - self.append(text, self.current_style()); + self.append_linkified(text, self.current_style()); } fn append(&mut self, text: &str, style: TextPanelSpanStyle) { + self.append_with_link(text, style, self.links.last().cloned().flatten()); + } + + fn append_linkified(&mut self, text: &str, style: TextPanelSpanStyle) { + if let Some(link) = self.links.last().cloned().flatten() { + self.append_with_link(text, style, Some(link)); + return; + } + for (fragment, target) in linkify_source_locations(text) { + let link = target.map(|target| self.new_link(target)); + let fragment_style = if link.is_some() { + TextPanelSpanStyle::Link + } else { + style + }; + self.append_with_link(fragment, fragment_style, link); + } + } + + fn append_with_link( + &mut self, + text: &str, + style: TextPanelSpanStyle, + link: Option, + ) { if text.is_empty() { return; } if let Some(table) = self.table.as_mut() { if let Some(cell) = table.current_cell.as_mut() { - push_span(&mut cell.spans, text.to_string(), style); + push_span_with_link(&mut cell.spans, text.to_string(), style, link); } return; } - push_span(&mut self.current, text.to_string(), style); + push_span_with_link(&mut self.current, text.to_string(), style, link); + } + + fn new_link(&mut self, target: TextPanelLinkTarget) -> TextPanelLink { + let link = TextPanelLink { + id: self.next_link_id, + target, + }; + self.next_link_id = self.next_link_id.saturating_add(1); + link } fn flush_current(&mut self) { @@ -470,17 +526,51 @@ pub(super) fn wrap_plain_text( if width == 0 { return Vec::new(); } + let mut next_link_id = 0; text.split('\n') .flat_map(|line| { - let spans = vec![RenderedTextSpan { - text: line.trim_end_matches('\r').to_string(), - style, - }]; + let spans = linkified_spans(line.trim_end_matches('\r'), style, &mut next_link_id); wrap_spans(&spans, width, &[], &[]) }) .collect() } +fn linkified_spans( + text: &str, + style: TextPanelSpanStyle, + next_link_id: &mut u64, +) -> Vec { + if text.is_empty() { + return vec![RenderedTextSpan { + text: String::new(), + style, + link: None, + }]; + } + linkify_source_locations(text) + .into_iter() + .map(|(text, target)| { + let link = target.map(|target| { + let link = TextPanelLink { + id: *next_link_id, + target, + }; + *next_link_id = next_link_id.saturating_add(1); + link + }); + RenderedTextSpan { + text: text.to_string(), + style: if link.is_some() { + TextPanelSpanStyle::Link + } else { + style + }, + link, + } + }) + .collect() +} + pub(super) fn render_markdown_lines(text: &str, width: usize) -> Vec { if width == 0 || text.is_empty() { return Vec::new(); @@ -512,7 +602,10 @@ fn wrap_spans( for token in tokens { if token.whitespace { if content_width > 0 { - pending_space = token.spans.first().map(|span| span.style); + pending_space = token + .spans + .first() + .map(|span| (span.style, span.link.clone())); } continue; } @@ -525,9 +618,9 @@ fn wrap_spans( content_width = 0; pending_space = None; } - if let Some(style) = pending_space.take() { + if let Some((style, link)) = pending_space.take() { if content_width > 0 { - push_span(&mut prefix, " ".to_string(), style); + push_span_with_link(&mut prefix, " ".to_string(), style, link); content_width += 1; } } @@ -535,7 +628,7 @@ fn wrap_spans( let available = width.saturating_sub(prefix_width).max(1); if token.width <= available.saturating_sub(content_width) { for span in token.spans { - push_span(&mut prefix, span.text, span.style); + push_span_with_link(&mut prefix, span.text, span.style, span.link); } content_width += token.width; continue; @@ -555,10 +648,20 @@ fn wrap_spans( prefix.clear(); } if grapheme_width > width { - push_span(&mut prefix, "…".to_string(), span.style); + push_span_with_link( + &mut prefix, + "…".to_string(), + span.style, + span.link.clone(), + ); content_width += 1; } else { - push_span(&mut prefix, grapheme.to_string(), span.style); + push_span_with_link( + &mut prefix, + grapheme.to_string(), + span.style, + span.link.clone(), + ); content_width += grapheme_width; } } @@ -595,10 +698,15 @@ fn wrap_verbatim( current.clear(); } if grapheme_width > width { - push_span(&mut current, "…".to_string(), span.style); + push_span_with_link(&mut current, "…".to_string(), span.style, span.link.clone()); content_width += 1; } else { - push_span(&mut current, grapheme.to_string(), span.style); + push_span_with_link( + &mut current, + grapheme.to_string(), + span.style, + span.link.clone(), + ); content_width += grapheme_width; } } @@ -623,7 +731,12 @@ fn styled_tokens(spans: &[RenderedTextSpan]) -> Vec { }); } if let Some(token) = tokens.last_mut() { - push_span(&mut token.spans, grapheme.to_string(), span.style); + push_span_with_link( + &mut token.spans, + grapheme.to_string(), + span.style, + span.link.clone(), + ); token.width += display_width(grapheme); } } @@ -740,11 +853,13 @@ fn push_table_row( vec![RenderedTextSpan { text: cell_text(cell), style: default_style, + link: None, }] } else if cell.spans.is_empty() { vec![RenderedTextSpan { text: String::new(), style: default_style, + link: None, }] } else { cell.spans.clone() @@ -776,7 +891,12 @@ fn push_table_row( }; push_span(&mut output, " ".repeat(left), TextPanelSpanStyle::Text); for span in spans { - push_span(&mut output, span.text.clone(), span.style); + push_span_with_link( + &mut output, + span.text.clone(), + span.style, + span.link.clone(), + ); } if column + 1 < widths.len() { push_span(&mut output, " ".repeat(right), TextPanelSpanStyle::Text); @@ -810,6 +930,7 @@ fn render_table_records( &[RenderedTextSpan { text: label, style: TextPanelSpanStyle::Heading, + link: None, }], width, prefix, @@ -826,6 +947,7 @@ fn render_table_records( vec![RenderedTextSpan { text: "—".to_string(), style: TextPanelSpanStyle::Muted, + link: None, }] } else { value.spans.clone() @@ -875,7 +997,12 @@ fn fit_prefix(spans: &[RenderedTextSpan], width: usize) -> Vec if used + grapheme_width > width { return out; } - push_span(&mut out, grapheme.to_string(), span.style); + push_span_with_link( + &mut out, + grapheme.to_string(), + span.style, + span.link.clone(), + ); used += grapheme_width; } } @@ -883,16 +1010,25 @@ fn fit_prefix(spans: &[RenderedTextSpan], width: usize) -> Vec } fn push_span(spans: &mut Vec, text: String, style: TextPanelSpanStyle) { + push_span_with_link(spans, text, style, None); +} + +fn push_span_with_link( + spans: &mut Vec, + text: String, + style: TextPanelSpanStyle, + link: Option, +) { if text.is_empty() { return; } if let Some(last) = spans.last_mut() { - if last.style == style { + if last.style == style && last.link == link { last.text.push_str(&text); return; } } - spans.push(RenderedTextSpan { text, style }); + spans.push(RenderedTextSpan { text, style, link }); } #[cfg(test)] @@ -950,7 +1086,7 @@ mod tests { ); let output = plain(&lines).join("\n"); - assert!(output.contains("◆ Accepted arguments")); + assert!(output.contains("▍ Accepted arguments")); assert!(output.contains("│ quoted words")); assert!(output.contains("┌─ rust")); assert!(output.contains("│ fn main() {}")); @@ -972,11 +1108,49 @@ mod tests { .iter() .flatten_spans() .any(|span| { span.text == "value" && span.style == TextPanelSpanStyle::InlineCode })); - assert!(lines + assert!(lines.iter().flatten_spans().any(|span| { + span.text == "link" + && span.style == TextPanelSpanStyle::Link + && span.link.as_ref().is_some_and(|link| { + link.target + == TextPanelLinkTarget::ExternalUrl("https://example.com".to_string()) + }) + })); + assert_fits(&lines, 44); + } + + #[test] + fn source_locations_are_links_in_plain_and_markdown_text() { + let plain_lines = wrap_plain_text("See src/editor.rs:42:7.", 80, TextPanelSpanStyle::Text); + let markdown_lines = render_markdown_lines("Open `README.md:8`.", 80); + + let plain_link = plain_lines .iter() .flatten_spans() - .any(|span| span.text == "link" && span.style == TextPanelSpanStyle::Link)); - assert_fits(&lines, 44); + .find_map(|span| span.link.as_ref()) + .unwrap(); + assert_eq!( + plain_link.target, + TextPanelLinkTarget::File { + path: "src/editor.rs".to_string(), + location: Some(TextPanelFileLocation { + line: 42, + column: 7, + }), + } + ); + let markdown_link = markdown_lines + .iter() + .flatten_spans() + .find_map(|span| span.link.as_ref()) + .unwrap(); + assert_eq!( + markdown_link.target, + TextPanelLinkTarget::File { + path: "README.md".to_string(), + location: Some(TextPanelFileLocation { line: 8, column: 1 }), + } + ); } #[test] diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index f0792cb5..2f059060 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -9,6 +9,7 @@ pub mod panel; pub mod process; mod registry; mod runtime; +mod text_link; pub mod timer_stats; pub mod window_bar; pub mod workspace; @@ -21,9 +22,13 @@ pub use overlay::{OverlayAlignment, OverlayConfig, OverlayManager}; pub use panel::{ PanelConfig, PanelManager, PanelRow, PanelRowKind, PanelSegment, PanelSide, TextPanelBlock, TextPanelBlockFormat, TextPanelBlockKind, TextPanelComposerConfig, TextPanelHeaderAction, + TextPanelStatus, }; pub use registry::{PluginRegistry, PluginStatus, RED_HOST_API_VERSION}; pub use runtime::{poll_timer_callbacks, RegisteredPluginCommand, Runtime}; +#[cfg(test)] +pub(crate) use text_link::TextPanelFileLocation; +pub(crate) use text_link::TextPanelLinkTarget; pub use window_bar::{ RenderedWindowBar, WindowBarConfig, WindowBarEdge, WindowBarHitRegion, WindowBarManager, WindowBarOverflow, WindowBarSegment, WindowBarSemanticStyle, WindowBarStyle, diff --git a/src/plugin/panel.rs b/src/plugin/panel.rs index 3d6a7182..269448d4 100644 --- a/src/plugin/panel.rs +++ b/src/plugin/panel.rs @@ -1,11 +1,12 @@ -use std::collections::HashMap; +use std::{collections::HashMap, time::Instant}; use crossterm::event::{Event, KeyCode, KeyModifiers}; use serde::{Deserialize, Serialize}; use super::markdown::{ - render_markdown_lines, wrap_plain_text, RenderedTextLine, TextPanelSpanStyle, + render_markdown_lines, wrap_plain_text, RenderedTextLine, RenderedTextSpan, TextPanelSpanStyle, }; +use super::text_link::{TextPanelLink, TextPanelLinkTarget}; use crate::{ editor::{render_buffer::RenderBuffer, Point}, theme::{SelectionForegroundPriority, Style, Theme, ThemeStyleSpec}, @@ -134,6 +135,8 @@ pub enum TextPanelBlockKind { User, Agent, Error, + /// Muted tool/progress timeline emitted while an agent turn runs. + Activity, #[default] Text, } @@ -159,6 +162,22 @@ pub struct TextPanelBlock { pub text: String, } +/// Turn-scoped progress state rendered in a dedicated panel status row. +/// +/// While `busy`, the core animates a spinner and shows the time elapsed since +/// the panel first became busy; `stream` appends a cursor to the last rendered +/// line to show that text is still arriving. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TextPanelStatus { + #[serde(default)] + pub busy: bool, + #[serde(default)] + pub label: String, + #[serde(default)] + pub stream: bool, +} + pub struct TextPanel { pub id: String, pub config: PanelConfig, @@ -166,6 +185,25 @@ pub struct TextPanel { pub scroll: usize, pub follow_tail: bool, composer: Option, + status: Option, + busy_since: Option, + selected_link: Option, +} + +const TEXT_PANEL_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const TEXT_PANEL_SPINNER_INTERVAL_MS: u64 = 120; + +fn spinner_frame(elapsed_ms: u64) -> &'static str { + let index = (elapsed_ms / TEXT_PANEL_SPINNER_INTERVAL_MS) as usize; + TEXT_PANEL_SPINNER_FRAMES[index % TEXT_PANEL_SPINNER_FRAMES.len()] +} + +fn format_elapsed(seconds: u64) -> String { + if seconds < 60 { + format!("{seconds}s") + } else { + format!("{}m{:02}s", seconds / 60, seconds % 60) + } } const MAX_COMPOSER_BYTES: usize = 128 * 1024; @@ -307,9 +345,25 @@ impl TextPanel { scroll: 0, follow_tail: true, composer, + status: None, + busy_since: None, + selected_link: None, } } + fn set_status(&mut self, status: Option) { + self.busy_since = if status.as_ref().is_some_and(|status| status.busy) { + self.busy_since.or_else(|| Some(Instant::now())) + } else { + None + }; + self.status = status; + } + + fn status_height(&self) -> usize { + usize::from(self.status.is_some()) + } + fn update_blocks( &mut self, blocks: Vec, @@ -390,6 +444,7 @@ impl TextPanel { self.config.title.is_some() || !self.config.header_actions.is_empty(), )) .saturating_sub(self.composer_height()) + .saturating_sub(self.status_height()) .max(1) } @@ -416,22 +471,111 @@ impl TextPanel { .map(|block| block.text.clone()) } - fn rendered_lines(&self, width: usize) -> Vec { - let mut lines = Vec::new(); - for block in &self.blocks { - if let Some((label, style)) = block_label(&block.kind) { - lines.push(RenderedTextLine::plain(label.to_string(), style)); + fn links(&self, width: usize) -> Vec<(TextPanelLink, usize)> { + let mut links = Vec::new(); + for (line_index, line) in self.rendered_lines(width).into_iter().enumerate() { + for span in line.spans { + let Some(link) = span.link else { + continue; + }; + if links + .last() + .is_none_or(|(previous, _): &(TextPanelLink, usize)| previous.id != link.id) + { + links.push((link, line_index)); + } } + } + links + } - 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), - }; - if block_lines.is_empty() { - block_lines.push(RenderedTextLine::plain(String::new(), style)); + fn select_link(&mut self, forward: bool, panel_height: usize, width: usize) -> bool { + let links = self.links(width); + if links.is_empty() { + self.selected_link = None; + return false; + } + let current = self + .selected_link + .and_then(|selected| links.iter().position(|(link, _)| link.id == selected)); + let index = match (current, forward) { + (Some(index), true) => (index + 1) % links.len(), + (Some(0), false) => links.len() - 1, + (Some(index), false) => index - 1, + (None, true) => 0, + (None, false) => links.len() - 1, + }; + let (link, line) = &links[index]; + self.selected_link = Some(link.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 + } + + fn selected_link_target(&self, width: usize) -> Option { + let selected = self.selected_link?; + self.links(width) + .into_iter() + .find(|(link, _)| link.id == selected) + .map(|(link, _)| link.target) + } + + fn rendered_lines(&self, width: usize) -> Vec { + let mut lines: Vec = Vec::new(); + for (block_index, block) in self.blocks.iter().enumerate() { + if block.kind == TextPanelBlockKind::User { + // A new user message starts a turn: separate it with a light + // rule and mark its lines with an accent bar instead of a + // one-line label. + if let Some(last) = lines.last_mut() { + if last.is_empty() { + *last = turn_separator(width); + } else { + lines.push(turn_separator(width)); + } + } + lines.push(RenderedTextLine::plain( + "▎ You".to_string(), + TextPanelSpanStyle::User, + )); + let content_width = width.saturating_sub(2).max(1); + let mut block_lines = match block.format { + TextPanelBlockFormat::Plain => { + wrap_plain_text(&block.text, content_width, TextPanelSpanStyle::Text) + } + TextPanelBlockFormat::Markdown => { + render_markdown_lines(&block.text, content_width) + } + }; + if block_lines.is_empty() { + block_lines.push(RenderedTextLine::plain( + String::new(), + TextPanelSpanStyle::Text, + )); + } + namespace_block_links(&mut block_lines, block_index); + lines.extend(block_lines.into_iter().map(user_accented)); + } else { + if let Some((label, style)) = block_label(&block.kind) { + lines.push(RenderedTextLine::plain(label.to_string(), style)); + } + + 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), + }; + if block_lines.is_empty() { + block_lines.push(RenderedTextLine::plain(String::new(), style)); + } + namespace_block_links(&mut block_lines, block_index); + lines.extend(block_lines); } - lines.extend(block_lines); lines.push(RenderedTextLine::plain( String::new(), TextPanelSpanStyle::Text, @@ -440,10 +584,28 @@ impl TextPanel { if lines.last().is_some_and(RenderedTextLine::is_empty) { lines.pop(); } + if self.status.as_ref().is_some_and(|status| status.stream) { + if let Some(last) = lines.last_mut() { + last.spans.push(RenderedTextSpan { + text: "▌".to_string(), + style: TextPanelSpanStyle::User, + link: None, + }); + } + } lines } } +fn namespace_block_links(lines: &mut [RenderedTextLine], block_index: usize) { + let namespace = (block_index as u64).saturating_add(1) << 32; + for span in lines.iter_mut().flat_map(|line| &mut line.spans) { + if let Some(link) = span.link.as_mut() { + link.id |= namespace; + } + } +} + pub struct PluginPanel { pub id: String, pub config: PanelConfig, @@ -544,6 +706,7 @@ pub struct PanelManager { text_panels: HashMap, z_order: Vec, focused: Option, + animation_state: Vec<(String, u8, u64)>, } impl PanelManager { @@ -768,6 +931,81 @@ impl PanelManager { }) } + pub(crate) fn select_focused_text_link( + &mut self, + forward: bool, + panel_height: usize, + terminal_width: usize, + ) -> bool { + let Some(focused) = self.focused.clone() else { + return false; + }; + let Some(panel) = self.text_panels.get_mut(&focused) else { + return false; + }; + let width = effective_panel_width(&panel.config, terminal_width); + if let Some(composer) = panel.composer.as_mut() { + composer.focused = false; + } + panel.select_link(forward, panel_height, width) + } + + pub(crate) fn focused_text_link_target( + &self, + terminal_width: usize, + ) -> Option { + let panel = self.text_panels.get(self.focused.as_deref()?)?; + let width = effective_panel_width(&panel.config, terminal_width); + panel.selected_link_target(width) + } + + pub(crate) fn text_link_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)?; + let panel = self.text_panels.get_mut(&placement.id)?; + let title_rows = + usize::from(panel.config.title.is_some() || !panel.config.header_actions.is_empty()); + let content_height = placement + .height + .saturating_sub(panel.composer_height()) + .saturating_sub(panel.status_height()); + let screen_row = y.saturating_sub(placement.y); + if screen_row < title_rows || screen_row >= content_height { + return None; + } + + let lines = panel.rendered_lines(placement.width); + let visible_rows = content_height.saturating_sub(title_rows); + let max_scroll = lines.len().saturating_sub(visible_rows); + let scroll = if panel.follow_tail { + max_scroll + } else { + panel.scroll.min(max_scroll) + }; + let line = lines.get(scroll + screen_row - title_rows)?; + let column = x.saturating_sub(placement.x); + let mut used = 0usize; + for span in &line.spans { + let end = used.saturating_add(display_width(&span.text)); + if column >= used && column < end { + let link = span.link.as_ref()?; + 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()); + } + used = end; + } + None + } + pub fn focused_text_for_copy(&self, all: bool) -> Option { let panel = self.text_panels.get(self.focused.as_deref()?)?; if all { @@ -817,6 +1055,41 @@ impl PanelManager { true } + pub fn set_text_panel_status(&mut self, id: &str, status: Option) -> bool { + let Some(panel) = self.text_panels.get_mut(id) else { + return false; + }; + panel.set_status(status); + true + } + + /// Advance spinner/elapsed state for visible busy panels. + /// + /// Returns true when the animation moved and the screen needs a repaint. + pub fn poll_animation(&mut self) -> bool { + let mut state = self + .z_order + .iter() + .filter_map(|id| { + let panel = self.text_panels.get(id)?; + if !panel.status.as_ref()?.busy { + return None; + } + let elapsed_ms = panel.busy_since?.elapsed().as_millis() as u64; + let frame = (elapsed_ms / TEXT_PANEL_SPINNER_INTERVAL_MS) + % TEXT_PANEL_SPINNER_FRAMES.len() as u64; + Some((id.clone(), frame as u8, elapsed_ms / 1000)) + }) + .collect::>(); + state.sort(); + if state == self.animation_state { + false + } else { + self.animation_state = state; + true + } + } + pub fn clear_text_panel_composer(&mut self, id: &str) -> bool { let Some(composer) = self .text_panels @@ -1242,7 +1515,10 @@ fn render_text_panel( } let composer_height = panel.composer_height(); - let content_height = height.saturating_sub(composer_height); + let status_height = panel.status_height(); + let content_height = height + .saturating_sub(composer_height) + .saturating_sub(status_height); let visible_rows = content_height.saturating_sub(title_rows); let lines = panel.rendered_lines(width); let max_scroll = lines.len().saturating_sub(visible_rows); @@ -1252,11 +1528,38 @@ fn render_text_panel( panel.scroll.min(max_scroll) }; for (offset, line) in lines.iter().skip(scroll).take(visible_rows).enumerate() { - render_text_spans(buffer, position.x, title_rows + offset, width, line, theme); + render_text_spans( + buffer, + position.x, + title_rows + offset, + width, + line, + panel.selected_link, + theme, + ); + } + + if let Some(status) = &panel.status { + render_text_panel_status( + buffer, + panel, + status, + position, + width, + content_height, + theme, + ); } if let Some(composer) = &panel.composer { - render_text_panel_composer(buffer, composer, position, width, content_height, theme); + render_text_panel_composer( + buffer, + composer, + position, + width, + content_height + status_height, + theme, + ); } render_panel_separator( @@ -1280,15 +1583,7 @@ fn render_text_panel_composer( if width == 0 { return; } - let divider_hint = if composer.focused { - "Esc nav · x clear · N new · q close" - } else { - "a edit · x clear · N new · q close" - }; - let divider = format!( - "{} {divider_hint}", - "─".repeat(width.saturating_sub(display_width(divider_hint).saturating_add(1))) - ); + let divider = "─".repeat(width); buffer.set_text( position.x, top, @@ -1344,6 +1639,37 @@ fn render_text_panel_composer( ); } +fn render_text_panel_status( + buffer: &mut RenderBuffer, + panel: &TextPanel, + status: &TextPanelStatus, + position: Point, + width: usize, + y: usize, + theme: &Theme, +) { + if width == 0 { + return; + } + let (text, style) = if status.busy { + let elapsed_ms = panel + .busy_since + .map_or(0, |since| since.elapsed().as_millis() as u64); + ( + format!( + "{} {} · {}", + spinner_frame(elapsed_ms), + status.label, + format_elapsed(elapsed_ms / 1000) + ), + &theme.ui_style.picker_prompt, + ) + } else { + (status.label.clone(), &theme.ui_style.muted) + }; + buffer.set_text(position.x, y, &fit_display_width(&text, width), style); +} + fn text_panel_header_actions(config: &PanelConfig, width: usize) -> Vec<(usize, &str, &str)> { let title_width = config.title.as_deref().map_or(0, display_width).min(5); let full_width = config @@ -1403,6 +1729,7 @@ fn render_text_spans( y: usize, width: usize, line: &RenderedTextLine, + selected_link: Option, theme: &Theme, ) { let mut used = 0; @@ -1414,7 +1741,15 @@ fn render_text_spans( if text.is_empty() { continue; } - let style = text_panel_span_style(span.style, theme); + let mut style = text_panel_span_style(span.style, theme); + if span + .link + .as_ref() + .is_some_and(|link| Some(link.id) == selected_link) + { + let selection = theme.list_selection_style(); + style = theme.selected_style(&style, &selection, SelectionForegroundPriority::Content); + } buffer.set_text(x + used, y, &text, &style); used += display_width(&text); } @@ -1474,10 +1809,11 @@ fn render_panel_separator( fn block_label(kind: &TextPanelBlockKind) -> Option<(&'static str, TextPanelSpanStyle)> { match kind { - TextPanelBlockKind::User => Some(("❯ You", TextPanelSpanStyle::User)), + // User blocks render a rule + accent bar instead of a label. + TextPanelBlockKind::User => None, TextPanelBlockKind::Agent => Some(("◆ Agent", TextPanelSpanStyle::Agent)), TextPanelBlockKind::Error => Some(("⚠ Error", TextPanelSpanStyle::Error)), - TextPanelBlockKind::Text => None, + TextPanelBlockKind::Activity | TextPanelBlockKind::Text => None, } } @@ -1486,10 +1822,25 @@ fn block_style(kind: &TextPanelBlockKind) -> TextPanelSpanStyle { TextPanelBlockKind::User => TextPanelSpanStyle::User, TextPanelBlockKind::Agent => TextPanelSpanStyle::Agent, TextPanelBlockKind::Error => TextPanelSpanStyle::Error, + TextPanelBlockKind::Activity => TextPanelSpanStyle::Muted, TextPanelBlockKind::Text => TextPanelSpanStyle::Text, } } +fn turn_separator(width: usize) -> RenderedTextLine { + RenderedTextLine::plain("─".repeat(width.max(1)), TextPanelSpanStyle::Muted) +} + +fn user_accented(line: RenderedTextLine) -> RenderedTextLine { + let mut spans = vec![RenderedTextSpan { + text: "▎ ".to_string(), + style: TextPanelSpanStyle::User, + link: None, + }]; + spans.extend(line.spans); + RenderedTextLine { spans } +} + fn render_row_segments( buffer: &mut RenderBuffer, x: usize, @@ -1866,6 +2217,71 @@ mod tests { assert_eq!(actions[0].1, "close"); } + #[test] + fn text_panel_links_support_keyboard_navigation_and_clicks() { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 40, + title: Some("Agent".to_string()), + composer: None, + header_actions: Vec::new(), + }, + ); + manager.update_text_panel( + "agent", + vec![TextPanelBlock { + id: "answer".to_string(), + kind: TextPanelBlockKind::Agent, + format: TextPanelBlockFormat::Markdown, + text: "[docs](https://example.com) and src/main.rs".to_string(), + }], + 18, + 80, + ); + assert!(manager.focus_panel("agent")); + + assert!(manager.select_focused_text_link(true, 18, 80)); + assert_eq!( + manager.focused_text_link_target(80), + Some(TextPanelLinkTarget::ExternalUrl( + "https://example.com".to_string() + )) + ); + assert!(manager.select_focused_text_link(true, 18, 80)); + assert_eq!( + manager.focused_text_link_target(80), + Some(TextPanelLinkTarget::File { + path: "src/main.rs".to_string(), + location: None, + }) + ); + assert!(manager.select_focused_text_link(false, 18, 80)); + assert_eq!( + manager.focused_text_link_target(80), + Some(TextPanelLinkTarget::ExternalUrl( + "https://example.com".to_string() + )) + ); + + let placement = manager.panel_at_position(40, 0, 80, 20).unwrap(); + assert_eq!( + manager.text_link_at_position(placement.x + 1, 2, 80, 20), + Some(TextPanelLinkTarget::ExternalUrl( + "https://example.com".to_string() + )) + ); + assert_eq!( + manager.text_link_at_position(placement.x + 11, 2, 80, 20), + Some(TextPanelLinkTarget::File { + path: "src/main.rs".to_string(), + location: None, + }) + ); + } + #[test] fn text_panel_composer_click_places_cursor_in_wrapped_text() { let mut manager = PanelManager::default(); @@ -1973,10 +2389,118 @@ mod tests { manager.render(&mut buffer, &theme); - assert!(row_text(&buffer, 9).contains("a edit · x clear · N new · q close")); + assert!(row_text(&buffer, 9).contains("────")); + assert!(!row_text(&buffer, 9).contains("a edit")); assert!(row_text(&buffer, 12).contains("Working · 1 queued")); } + #[test] + fn text_panel_status_row_shows_spinner_label_elapsed_and_stream_cursor() { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 70, + title: Some("Agent".to_string()), + composer: Some(TextPanelComposerConfig { + placeholder: "Ask".to_string(), + rows: 2, + }), + header_actions: Vec::new(), + }, + ); + manager.update_text_panel( + "agent", + vec![TextPanelBlock { + id: "agent:1".to_string(), + kind: TextPanelBlockKind::Agent, + format: TextPanelBlockFormat::Plain, + text: "partial answer".to_string(), + }], + 13, + 100, + ); + assert!(manager.set_text_panel_status( + "agent", + Some(TextPanelStatus { + busy: true, + label: "Reading demo.txt".to_string(), + stream: true, + }), + )); + let theme = Theme::default(); + let mut buffer = RenderBuffer::new(100, 15, &theme.style); + + manager.render(&mut buffer, &theme); + + let status_row = row_text(&buffer, 8); + assert!(status_row.contains("⠋ Reading demo.txt · 0s")); + assert!(row_text(&buffer, 9).contains("────")); + assert!((1..8).any(|row| row_text(&buffer, row).contains("partial answer▌"))); + + assert!(manager.set_text_panel_status("agent", None)); + let mut buffer = RenderBuffer::new(100, 15, &theme.style); + manager.render(&mut buffer, &theme); + assert!(!row_text(&buffer, 8).contains("Reading demo.txt")); + assert!((1..9).any(|row| row_text(&buffer, row).contains("partial answer"))); + assert!(!(1..9).any(|row| row_text(&buffer, row).contains("partial answer▌"))); + } + + #[test] + fn activity_blocks_render_muted_without_a_label_between_turns() { + let mut manager = PanelManager::default(); + manager.create_text_panel( + "agent".to_string(), + PanelConfig { + side: PanelSide::Right, + width: 40, + title: None, + composer: None, + header_actions: Vec::new(), + }, + ); + manager.update_text_panel( + "agent", + vec![ + TextPanelBlock { + id: "user:1".to_string(), + kind: TextPanelBlockKind::User, + format: TextPanelBlockFormat::Plain, + text: "first".to_string(), + }, + TextPanelBlock { + id: "activity:2".to_string(), + kind: TextPanelBlockKind::Activity, + format: TextPanelBlockFormat::Plain, + text: "✓ Read demo.txt".to_string(), + }, + TextPanelBlock { + id: "user:3".to_string(), + kind: TextPanelBlockKind::User, + format: TextPanelBlockFormat::Plain, + text: "second".to_string(), + }, + ], + 20, + 60, + ); + let theme = Theme::default(); + let mut buffer = RenderBuffer::new(60, 22, &theme.style); + + manager.render(&mut buffer, &theme); + + let rendered = (0..22) + .map(|row| row_text(&buffer, row)) + .collect::>(); + let joined = rendered.join("\n"); + assert!(joined.contains("▎ You")); + assert!(joined.contains("✓ Read demo.txt")); + assert!(!joined.contains("❯ You")); + let separator_rows = rendered.iter().filter(|row| row.contains("────")).count(); + assert_eq!(separator_rows, 1); + } + #[test] fn text_panel_append_follows_tail_until_user_scrolls() { let mut panel = TextPanel::new( @@ -2125,8 +2649,8 @@ mod tests { manager.render(&mut buffer, &theme); - assert!(row_text(&buffer, 0).contains("│❯ You")); - assert!(row_text(&buffer, 1).contains("│hello")); + assert!(row_text(&buffer, 0).contains("│▎ You")); + assert!(row_text(&buffer, 1).contains("│▎ hello")); assert!(manager.panel_at_position(7, 0, 16, 7).is_none()); assert!(manager.panel_at_position(8, 0, 16, 7).is_some()); } diff --git a/src/plugin/runtime.rs b/src/plugin/runtime.rs index ad81e794..f6e6fa06 100644 --- a/src/plugin/runtime.rs +++ b/src/plugin/runtime.rs @@ -19,8 +19,8 @@ use crate::{ }; use super::{ - Decoration, GutterSign, OverlayConfig, PanelConfig, PanelRow, TextPanelBlock, WindowBarConfig, - WindowBarSegment, + Decoration, GutterSign, OverlayConfig, PanelConfig, PanelRow, TextPanelBlock, TextPanelStatus, + WindowBarConfig, WindowBarSegment, }; use super::{WorkspaceConfig, WorkspaceModel}; @@ -790,6 +790,18 @@ impl Host for RedHost { status, }); } + "SetTextPanelStatus" => { + let id = args + .first() + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("SetTextPanelStatus requires a panel id"))? + .to_string(); + let status = match args.get(1).map(value_to_json) { + None | Some(serde_json::Value::Null) => None, + Some(value) => Some(serde_json::from_value::(value)?), + }; + self.send_request(PluginRequest::SetTextPanelStatus { id, status }); + } "ClearTextPanelComposer" => { let id = args .first() @@ -1795,11 +1807,6 @@ mod tests { ACTION_DISPATCHER.recv_request(), PluginRequest::Action(Action::Print(message)) if message == "Agent session started" )); - assert!(matches!( - ACTION_DISPATCHER.recv_request(), - PluginRequest::SetPluginStorage { plugin, key, .. } - if plugin == "agent" && key == "transcript" - )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::UpdateTextPanel { id, blocks } @@ -1807,6 +1814,26 @@ mod tests { && blocks.len() == 1 && blocks[0].text == "explain the workspace" )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::FocusPanel { id } if id == "agent-conversation" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetTextPanelStatus { id, status: Some(status) } + if id == "agent-conversation" + && status.busy + && status.label == "Waiting for agent…" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::Action(Action::Refresh) + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPluginStorage { plugin, key, .. } + if plugin == "agent" && key == "transcript" + )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::AgentPrompt { session_id, text } @@ -1853,7 +1880,7 @@ mod tests { PluginRequest::CreateTextPanel { id, config } if id == "agent-conversation" && config.side == crate::plugin::PanelSide::Right - && config.width == 52 + && config.width == 62 && config.title.as_deref() == Some("Agent") && config.header_actions.iter().map(|action| action.id.as_str()).eq(["clear", "new", "close"]) )); @@ -1863,7 +1890,7 @@ mod tests { if id == "agent-conversation" && blocks.len() == 1 && blocks[0].id == "empty" - && blocks[0].kind == crate::plugin::TextPanelBlockKind::Text + && blocks[0].kind == crate::plugin::TextPanelBlockKind::Activity && blocks[0].format == crate::plugin::TextPanelBlockFormat::Plain )); assert!(matches!( @@ -1912,21 +1939,6 @@ mod tests { ) .await .unwrap(); - assert!(matches!( - ACTION_DISPATCHER.recv_request(), - PluginRequest::SetPluginStorage { plugin, key, value } - if plugin == "agent" - && key == "prompt_history" - && value == serde_json::json!([ - " inspect the workspace\ninclude all unsaved changes ", - "previous prompt" - ]) - )); - assert!(matches!( - ACTION_DISPATCHER.recv_request(), - PluginRequest::SetPluginStorage { plugin, key, .. } - if plugin == "agent" && key == "transcript" - )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::UpdateTextPanel { id, blocks } @@ -1937,12 +1949,43 @@ mod tests { && blocks[0].format == crate::plugin::TextPanelBlockFormat::Plain && blocks[0].text == " inspect the workspace\ninclude all unsaved changes " )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::FocusPanel { id } if id == "agent-conversation" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetTextPanelStatus { id, status: Some(status) } + if id == "agent-conversation" + && status.busy + && status.label == "Waiting for agent…" + && !status.stream + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::Action(Action::Refresh) + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPluginStorage { plugin, key, .. } + if plugin == "agent" && key == "transcript" + )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::AgentPrompt { session_id, text } if session_id == "session-1" && text == " inspect the workspace\ninclude all unsaved changes " )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPluginStorage { plugin, key, value } + if plugin == "agent" + && key == "prompt_history" + && value == serde_json::json!([ + " inspect the workspace\ninclude all unsaved changes ", + "previous prompt" + ]) + )); runtime .notify( "agent:update", @@ -1963,6 +2006,14 @@ mod tests { && blocks[1].format == crate::plugin::TextPanelBlockFormat::Markdown && blocks[1].text.is_empty() )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetTextPanelStatus { id, status: Some(status) } + if id == "agent-conversation" + && status.busy + && status.label == "Writing…" + && status.stream + )); runtime .notify( "agent:update", @@ -2083,7 +2134,8 @@ mod tests { "agent:completed", serde_json::json!({ "session_id": "session-1", - "stop_reason": "end_turn", + "stop_reason": "completed", + "elapsed_ms": 3_723_000, }), ) .await @@ -2097,9 +2149,17 @@ mod tests { )); assert!(matches!( ACTION_DISPATCHER.recv_request(), - PluginRequest::SetTextPanelComposerState { id, enabled: true, status } + PluginRequest::UpdateTextPanel { id, blocks } + if id == "agent-conversation" + && blocks.last().is_some_and(|block| { + block.kind == crate::plugin::TextPanelBlockKind::Activity + && block.text == "Worked for 1h 2m 3s" + }) + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetTextPanelStatus { id, status: None } if id == "agent-conversation" - && status.as_deref() == Some("Ready · Enter sends · ^J adds a line") )); runtime.execute_command("AgentCancel").await.unwrap(); @@ -2290,6 +2350,8 @@ mod tests { .unwrap(); let mut history_saved = false; let mut status = false; + let mut queued_visible = false; + let mut refreshed = false; while let Some(request) = ACTION_DISPATCHER.try_recv_request() { match request { PluginRequest::SetPluginStorage { plugin, key, value } @@ -2303,16 +2365,25 @@ mod tests { PluginRequest::Action(Action::Print(message)) => { status |= message.contains("turn is still running"); } - PluginRequest::AgentPrompt { .. } - | PluginRequest::UpdateTextPanel { .. } - | PluginRequest::AppendTextPanel { .. } => { - panic!("concurrent prompt changed the active conversation") + PluginRequest::UpdateTextPanel { blocks, .. } => { + queued_visible |= blocks.iter().any(|block| { + block.kind == crate::plugin::TextPanelBlockKind::User + && block.text == "concurrent prompt" + }); + } + PluginRequest::Action(Action::Refresh) => { + refreshed = true; + } + PluginRequest::AgentPrompt { .. } | PluginRequest::AppendTextPanel { .. } => { + panic!("concurrent prompt started before the active turn completed") } _ => {} } } assert!(history_saved); assert!(status); + assert!(queued_visible); + assert!(refreshed); runtime .notify( "agent:update", @@ -2329,31 +2400,65 @@ mod tests { .await .unwrap(); let mut closed = false; + let mut replacement_request_id = None; while let Some(request) = ACTION_DISPATCHER.try_recv_request() { - closed |= matches!( - request, - PluginRequest::AgentCloseSession { session_id } if session_id == "session-1" - ); + match request { + PluginRequest::AgentCloseSession { session_id } => { + closed |= session_id == "session-1"; + } + PluginRequest::GetConfig { request_id, key } => { + assert_eq!(key.as_deref(), Some("cwd")); + replacement_request_id = Some(request_id); + } + _ => {} + } } assert!(closed, "completed cancelled stream must rotate its session"); - runtime - .notify("composer:submitted:802", serde_json::json!("next prompt")) + .resolve_request( + replacement_request_id.expect("queued prompt must request a replacement session"), + serde_json::json!({ "value": "/workspace" }), + ) .await .unwrap(); - let mut replacement_requested = false; + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::AgentNewSession { cwd } if cwd == Path::new("/workspace") + )); + runtime + .notify( + "agent:session_created", + serde_json::json!({ "session_id": "session-2" }), + ) + .await + .unwrap(); + let mut replacement_dispatched = false; + let mut dispatched_prompts = Vec::new(); while let Some(request) = ACTION_DISPATCHER.try_recv_request() { match request { - PluginRequest::GetConfig { key, .. } => { - replacement_requested |= key.as_deref() == Some("cwd"); + PluginRequest::UpdateTextPanel { blocks, .. } => { + assert!( + blocks + .iter() + .filter(|block| block.text == "concurrent prompt") + .count() + <= 1, + "a queued prompt must not duplicate during session rotation" + ); } - PluginRequest::AgentPrompt { session_id, .. } if session_id == "session-1" => { - panic!("next prompt must not reuse the cancelled session") + PluginRequest::AgentPrompt { session_id, text } => { + assert_ne!(session_id, "session-1"); + dispatched_prompts.push((session_id.clone(), text.clone())); + replacement_dispatched = session_id == "session-2" + && text.ends_with("Follow-up:\nconcurrent prompt"); } _ => {} } } - assert!(replacement_requested); + assert!( + replacement_dispatched, + "expected queued prompt on replacement session, got {dispatched_prompts:?}" + ); } #[tokio::test] @@ -2381,16 +2486,57 @@ mod tests { ) .await .unwrap(); - assert!(ACTION_DISPATCHER.try_recv_request().is_some()); let mut first = false; + let mut focused = false; + let mut rendered = false; + let mut busy = false; + let mut refreshed = false; while let Some(request) = ACTION_DISPATCHER.try_recv_request() { - first |= matches!( - request, - PluginRequest::AgentPrompt { session_id, text } - if session_id == "session-1" && text == "first prompt" - ); + match request { + PluginRequest::UpdateTextPanel { id, blocks } => { + rendered |= id == "agent-conversation" + && blocks.iter().any(|block| block.text == "first prompt"); + } + PluginRequest::FocusPanel { id } => { + focused |= id == "agent-conversation"; + } + PluginRequest::SetTextPanelStatus { + id, + status: Some(status), + } => { + busy |= id == "agent-conversation" + && status.busy + && status.label == "Waiting for agent…"; + } + PluginRequest::Action(Action::Refresh) => { + assert!(rendered, "the submitted text must be ready before refresh"); + assert!(busy, "the busy status must be ready before refresh"); + refreshed = true; + } + PluginRequest::AgentPrompt { session_id, text } => { + assert!( + refreshed, + "the conversation must render before agent dispatch" + ); + first |= session_id == "session-1" && text == "first prompt"; + } + _ => {} + } } assert!(first); + assert!(focused); + assert!(rendered); + assert!(busy); + assert!(refreshed); + + runtime + .notify( + "agent:update", + serde_json::json!({ "session_id": "session-1", "text": "first answer" }), + ) + .await + .unwrap(); + drain_requests(); for text in ["second prompt", "third prompt"] { runtime @@ -2402,8 +2548,25 @@ mod tests { .unwrap(); } let mut queued = 0; + let mut refreshes = 0; + let mut second_visible = false; + let mut third_visible = false; while let Some(request) = ACTION_DISPATCHER.try_recv_request() { match request { + PluginRequest::UpdateTextPanel { id, blocks } => { + assert_eq!(id, "agent-conversation"); + second_visible |= blocks.iter().any(|block| { + block.kind == crate::plugin::TextPanelBlockKind::User + && block.text == "second prompt" + }); + third_visible |= blocks.iter().any(|block| { + block.kind == crate::plugin::TextPanelBlockKind::User + && block.text == "third prompt" + }); + } + PluginRequest::Action(Action::Refresh) => { + refreshes += 1; + } PluginRequest::Action(Action::Print(message)) => { queued += usize::from(message.contains("follow-up queued")); } @@ -2414,25 +2577,110 @@ mod tests { } } assert_eq!(queued, 2); + assert_eq!(refreshes, 2); + assert!(second_visible); + assert!(third_visible); - for expected in ["second prompt", "third prompt"] { - runtime - .notify( - "agent:completed", - serde_json::json!({ "session_id": "session-1", "stop_reason": "end_turn" }), - ) - .await - .unwrap(); - let mut delivered = false; - while let Some(request) = ACTION_DISPATCHER.try_recv_request() { - delivered |= matches!( - request, - PluginRequest::AgentPrompt { session_id, text } - if session_id == "session-1" && text == expected - ); + runtime + .notify( + "agent:update", + serde_json::json!({ "session_id": "session-1", "text": " continues" }), + ) + .await + .unwrap(); + assert!( + ACTION_DISPATCHER.try_recv_request().is_none(), + "queueing must not end the active stream" + ); + + runtime + .notify( + "agent:completed", + serde_json::json!({ "session_id": "session-1", "stop_reason": "end_turn" }), + ) + .await + .unwrap(); + let mut delivered_second = false; + let mut refreshed_second = false; + while let Some(request) = ACTION_DISPATCHER.try_recv_request() { + assert!( + !matches!(&request, PluginRequest::FocusPanel { .. }), + "queued follow-ups must not steal panel focus" + ); + match request { + PluginRequest::UpdateTextPanel { blocks, .. } => { + assert_eq!( + blocks + .iter() + .filter(|block| block.text == "second prompt") + .count(), + 1, + "promoting a queued prompt must not duplicate its block" + ); + } + PluginRequest::Action(Action::Refresh) => { + refreshed_second = true; + } + PluginRequest::AgentPrompt { session_id, text } => { + assert!(refreshed_second); + delivered_second = session_id == "session-1" && text == "second prompt"; + } + _ => {} } - assert!(delivered, "expected queued prompt {expected:?}"); } + assert!(delivered_second); + + runtime + .notify( + "agent:update", + serde_json::json!({ "session_id": "session-1", "text": "second answer" }), + ) + .await + .unwrap(); + let mut ordered_before_pending = false; + while let Some(request) = ACTION_DISPATCHER.try_recv_request() { + if let PluginRequest::UpdateTextPanel { blocks, .. } = request { + let second_user = blocks + .iter() + .position(|block| block.text == "second prompt") + .unwrap(); + let second_agent = blocks + .iter() + .position(|block| { + block.kind == crate::plugin::TextPanelBlockKind::Agent + && block.id != "agent:2" + }) + .unwrap(); + let third_user = blocks + .iter() + .position(|block| block.text == "third prompt") + .unwrap(); + ordered_before_pending = second_user < second_agent && second_agent < third_user; + } + } + assert!( + ordered_before_pending, + "the active answer must render before later queued prompts" + ); + + runtime + .notify( + "agent:completed", + serde_json::json!({ "session_id": "session-1", "stop_reason": "end_turn" }), + ) + .await + .unwrap(); + let mut delivered_third = false; + while let Some(request) = ACTION_DISPATCHER.try_recv_request() { + assert!( + !matches!(&request, PluginRequest::FocusPanel { .. }), + "queued follow-ups must not steal panel focus" + ); + if let PluginRequest::AgentPrompt { session_id, text } = request { + delivered_third = session_id == "session-1" && text == "third prompt"; + } + } + assert!(delivered_third); } #[tokio::test] @@ -2527,6 +2775,42 @@ mod tests { drain_requests(); } + #[tokio::test] + async fn bundled_agent_open_creates_and_focuses_panel_without_starting_a_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.execute_command("AgentOpen").await.unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::CreateTextPanel { id, .. } if id == "agent-conversation" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::UpdateTextPanel { id, blocks } + if id == "agent-conversation" + && blocks.len() == 1 + && blocks[0].text.starts_with("No messages yet.") + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::FocusPanel { id } if id == "agent-conversation" + )); + assert!(ACTION_DISPATCHER.try_recv_request().is_none()); + + runtime.execute_command("AgentOpen").await.unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::FocusPanel { id } if id == "agent-conversation" + )); + assert!(ACTION_DISPATCHER.try_recv_request().is_none()); + } + #[tokio::test] async fn bundled_agent_close_reopens_without_recreating_and_new_resets_the_session() { let _lock = PLUGIN_DISPATCHER_TEST_LOCK.lock().await; @@ -2556,6 +2840,28 @@ mod tests { )); assert!(ACTION_DISPATCHER.try_recv_request().is_none()); + runtime.execute_command("AgentOpen").await.unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPanelVisible { id, visible: true } if id == "agent-conversation" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::FocusPanel { id } if id == "agent-conversation" + )); + assert!(ACTION_DISPATCHER.try_recv_request().is_none()); + + runtime.execute_command("AgentClose").await.unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPanelVisible { id, visible: false } if id == "agent-conversation" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::FocusEditor + )); + assert!(ACTION_DISPATCHER.try_recv_request().is_none()); + runtime.execute_command("AgentPrompt").await.unwrap(); assert!(matches!( ACTION_DISPATCHER.recv_request(), @@ -3138,6 +3444,11 @@ mod tests { ) .await .unwrap(); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetTextPanelStatus { id, status: None } + if id == "agent-conversation" + )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::AgentArchiveSession { session_id } if session_id == "session-1" @@ -3172,13 +3483,18 @@ mod tests { .unwrap(); assert!(matches!( ACTION_DISPATCHER.recv_request(), - PluginRequest::SetPluginStorage { plugin, key, .. } - if plugin == "agent" && key == "transcript" + PluginRequest::SetTextPanelStatus { id, status: None } + if id == "agent-conversation" )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::UpdateTextPanel { id, .. } if id == "agent-conversation" )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPluginStorage { plugin, key, .. } + if plugin == "agent" && key == "transcript" + )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::Action(Action::Print(message)) @@ -3282,13 +3598,23 @@ mod tests { .unwrap(); assert!(matches!( ACTION_DISPATCHER.recv_request(), - PluginRequest::SetPluginStorage { plugin, key, .. } - if plugin == "agent" && key == "transcript" + PluginRequest::SetTextPanelStatus { id, status: None } + if id == "agent-conversation" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetTextPanelStatus { id, status: None } + if id == "agent-conversation" )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::UpdateTextPanel { id, .. } if id == "agent-conversation" )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPluginStorage { plugin, key, .. } + if plugin == "agent" && key == "transcript" + )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::Action(Action::Print(message)) @@ -3752,13 +4078,18 @@ mod tests { .unwrap(); assert!(matches!( ACTION_DISPATCHER.recv_request(), - PluginRequest::SetPluginStorage { plugin, key, .. } - if plugin == "agent" && key == "transcript" + PluginRequest::SetTextPanelStatus { id, status: None } + if id == "agent-conversation" )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::UpdateTextPanel { id, .. } if id == "agent-conversation" )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPluginStorage { plugin, key, .. } + if plugin == "agent" && key == "transcript" + )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::Action(Action::Print(message)) @@ -3876,12 +4207,25 @@ mod tests { )); assert!(matches!( ACTION_DISPATCHER.recv_request(), - PluginRequest::SetPluginStorage { plugin, key, .. } - if plugin == "agent" && key == "transcript" + PluginRequest::UpdateTextPanel { id, .. } if id == "agent-conversation" )); assert!(matches!( ACTION_DISPATCHER.recv_request(), - PluginRequest::UpdateTextPanel { id, .. } if id == "agent-conversation" + PluginRequest::FocusPanel { id } if id == "agent-conversation" + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetTextPanelStatus { id, status: Some(status) } + if id == "agent-conversation" && status.busy + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::Action(Action::Refresh) + )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPluginStorage { plugin, key, .. } + if plugin == "agent" && key == "transcript" )); assert!(matches!( ACTION_DISPATCHER.recv_request(), @@ -3990,13 +4334,18 @@ mod tests { .unwrap(); assert!(matches!( ACTION_DISPATCHER.recv_request(), - PluginRequest::SetPluginStorage { plugin, key, .. } - if plugin == "agent" && key == "transcript" + PluginRequest::SetTextPanelStatus { id, status: None } + if id == "agent-conversation" )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::UpdateTextPanel { id, .. } if id == "agent-conversation" )); + assert!(matches!( + ACTION_DISPATCHER.recv_request(), + PluginRequest::SetPluginStorage { plugin, key, .. } + if plugin == "agent" && key == "transcript" + )); assert!(matches!( ACTION_DISPATCHER.recv_request(), PluginRequest::Action(Action::Print(message)) diff --git a/src/plugin/text_link.rs b/src/plugin/text_link.rs new file mode 100644 index 00000000..10044be7 --- /dev/null +++ b/src/plugin/text_link.rs @@ -0,0 +1,346 @@ +//! Link targets recognized in source-backed text panels. + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TextPanelLinkTarget { + File { + path: String, + location: Option, + }, + ExternalUrl(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TextPanelFileLocation { + /// One-based source line. + pub(crate) line: usize, + /// One-based source column. + pub(crate) column: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TextPanelLink { + pub(crate) id: u64, + pub(crate) target: TextPanelLinkTarget, +} + +pub(crate) fn markdown_link_target(destination: &str) -> Option { + let destination = destination.trim(); + let lowercase = destination.to_ascii_lowercase(); + if lowercase.starts_with("https://") || lowercase.starts_with("http://") { + return Some(TextPanelLinkTarget::ExternalUrl(destination.to_string())); + } + if destination.is_empty() + || destination.starts_with('#') + || destination.contains("://") + || destination.starts_with("mailto:") + { + return None; + } + + if let Some((path, line, column)) = parse_source_location(destination) { + return Some(TextPanelLinkTarget::File { + path, + location: Some(TextPanelFileLocation { line, column }), + }); + } + + Some(TextPanelLinkTarget::File { + path: destination.to_string(), + location: None, + }) +} + +pub(crate) fn linkify_source_locations(text: &str) -> Vec<(&str, Option)> { + let mut fragments = Vec::new(); + let mut cursor = 0; + + for (token_start, token) in whitespace_tokens(text) { + let leading = token + .char_indices() + .take_while(|(_, character)| { + matches!(character, '(' | '[' | '{' | '<' | '\'' | '"' | '`') + }) + .map(|(index, character)| index + character.len_utf8()) + .last() + .unwrap_or(0); + let candidate = &token[leading..]; + let candidate_len = candidate + .trim_end_matches(|character: char| { + matches!( + character, + '.' | ',' | ';' | '!' | '?' | ')' | ']' | '}' | '>' | '\'' | '"' | '`' + ) + }) + .len(); + let candidate = &candidate[..candidate_len]; + let target = parse_source_location(candidate) + .map(|(path, line, column)| TextPanelLinkTarget::File { + path, + location: Some(TextPanelFileLocation { line, column }), + }) + .or_else(|| { + is_bare_file_path(candidate).then(|| TextPanelLinkTarget::File { + path: candidate.to_string(), + location: None, + }) + }); + let Some(target) = target else { + continue; + }; + let start = token_start + leading; + let end = start + candidate.len(); + if cursor < start { + fragments.push((&text[cursor..start], None)); + } + fragments.push((&text[start..end], Some(target))); + cursor = end; + } + + if cursor < text.len() { + fragments.push((&text[cursor..], None)); + } + if fragments.is_empty() && !text.is_empty() { + fragments.push((text, None)); + } + fragments +} + +fn whitespace_tokens(text: &str) -> impl Iterator { + text.char_indices() + .filter(|(_, character)| !character.is_whitespace()) + .filter(|(index, _)| { + *index == 0 + || text[..*index] + .chars() + .next_back() + .is_some_and(char::is_whitespace) + }) + .map(|(start, _)| { + let end = text[start..] + .find(char::is_whitespace) + .map_or(text.len(), |offset| start + offset); + (start, &text[start..end]) + }) +} + +fn parse_source_location(value: &str) -> Option<(String, usize, usize)> { + if value.is_empty() || value.contains("://") { + return None; + } + + if let Some(fragment) = value.rfind("#L") { + let path = &value[..fragment]; + let location = &value[fragment + 2..]; + let (line, column) = location + .split_once('C') + .map_or((location, "1"), |(line, column)| (line, column)); + return valid_location(path, line, column); + } + + let (before_last, last) = value.rsplit_once(':')?; + if !is_positive_integer(last) { + return None; + } + if let Some((path, possible_line)) = before_last.rsplit_once(':') { + if is_positive_integer(possible_line) { + return valid_location(path, possible_line, last); + } + } + valid_location(before_last, last, "1") +} + +fn is_bare_file_path(value: &str) -> bool { + if value.is_empty() + || value.contains("://") + || value.starts_with("mailto:") + || value.contains('@') + || value.ends_with(['/', '\\']) + { + return false; + } + + let lowercase = value.to_ascii_lowercase(); + if matches!( + lowercase.as_str(), + "and/or" + | "either/or" + | "he/she" + | "him/her" + | "his/her" + | "input/output" + | "read/write" + | "true/false" + | "yes/no" + ) { + return false; + } + + let explicit = value.starts_with('/') + || value.starts_with("./") + || value.starts_with("../") + || value.starts_with("~/") + || value.starts_with("\\\\") + || value.starts_with(".\\") + || value.starts_with("..\\") + || value.starts_with("~\\") + || value.as_bytes().get(..3).is_some_and(|prefix| { + prefix[0].is_ascii_alphabetic() + && prefix[1] == b':' + && matches!(prefix[2], b'/' | b'\\') + }); + explicit + || value + .split(['/', '\\']) + .filter(|segment| !segment.is_empty()) + .count() + >= 2 +} + +fn valid_location(path: &str, line: &str, column: &str) -> Option<(String, usize, usize)> { + if path.is_empty() + || path.chars().all(|character| character.is_ascii_digit()) + || path.ends_with(':') + { + return None; + } + Some((path.to_string(), line.parse().ok()?, column.parse().ok()?)) +} + +fn is_positive_integer(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) && value != "0" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linkifies_source_locations_without_swallowing_punctuation() { + let fragments = linkify_source_locations( + "See src/editor.rs:42:7, (README.md:8) and https://example.com:443.", + ); + let links = fragments + .iter() + .filter_map(|(text, target)| target.as_ref().map(|target| (*text, target))) + .collect::>(); + + assert_eq!( + links, + [ + ( + "src/editor.rs:42:7", + &TextPanelLinkTarget::File { + path: "src/editor.rs".to_string(), + location: Some(TextPanelFileLocation { + line: 42, + column: 7, + }), + }, + ), + ( + "README.md:8", + &TextPanelLinkTarget::File { + path: "README.md".to_string(), + location: Some(TextPanelFileLocation { line: 8, column: 1 }), + }, + ), + ] + ); + } + + #[test] + fn linkifies_bare_file_paths_at_the_start_of_the_file() { + let fragments = linkify_source_locations( + "Open src/editor.rs, `path/file`, ./README.md, ../notes/todo, /tmp/log and ~/docs/a.", + ); + let links = fragments + .iter() + .filter_map(|(text, target)| target.as_ref().map(|target| (*text, target))) + .collect::>(); + + assert_eq!( + links, + [ + ( + "src/editor.rs", + &TextPanelLinkTarget::File { + path: "src/editor.rs".to_string(), + location: None, + }, + ), + ( + "path/file", + &TextPanelLinkTarget::File { + path: "path/file".to_string(), + location: None, + }, + ), + ( + "./README.md", + &TextPanelLinkTarget::File { + path: "./README.md".to_string(), + location: None, + }, + ), + ( + "../notes/todo", + &TextPanelLinkTarget::File { + path: "../notes/todo".to_string(), + location: None, + }, + ), + ( + "/tmp/log", + &TextPanelLinkTarget::File { + path: "/tmp/log".to_string(), + location: None, + }, + ), + ( + "~/docs/a", + &TextPanelLinkTarget::File { + path: "~/docs/a".to_string(), + location: None, + }, + ), + ] + ); + } + + #[test] + fn bare_paths_do_not_capture_urls_emails_or_common_slash_phrases() { + let fragments = linkify_source_locations( + "https://example.com/a mail@example.com yes/no and/or read/write", + ); + + assert!(fragments.iter().all(|(_, target)| target.is_none())); + } + + #[test] + fn classifies_markdown_destinations() { + assert_eq!( + markdown_link_target("https://example.com/docs"), + Some(TextPanelLinkTarget::ExternalUrl( + "https://example.com/docs".to_string() + )) + ); + assert_eq!( + markdown_link_target("src/main.rs#L12C4"), + Some(TextPanelLinkTarget::File { + path: "src/main.rs".to_string(), + location: Some(TextPanelFileLocation { + line: 12, + column: 4, + }), + }) + ); + assert_eq!( + markdown_link_target("README.md"), + Some(TextPanelLinkTarget::File { + path: "README.md".to_string(), + location: None, + }) + ); + assert_eq!(markdown_link_target("#section"), None); + } +} diff --git a/tests/detach.rs b/tests/detach.rs index 5f6efe43..024d91df 100644 --- a/tests/detach.rs +++ b/tests/detach.rs @@ -57,14 +57,35 @@ for line in sys.stdin: path } -async fn wait_for_file(path: &Path) { +async fn wait_for_pid(path: &Path) -> i32 { tokio::time::timeout(Duration::from_secs(5), async { - while !path.exists() { + loop { + if let Some(pid) = std::fs::read_to_string(path) + .ok() + .and_then(|contents| contents.trim().parse::().ok()) + { + return pid; + } tokio::time::sleep(Duration::from_millis(10)).await; } }) .await - .expect("Codex app-server fixture did not start"); + .expect("Codex app-server fixture did not write its PID") +} + +#[tokio::test(flavor = "current_thread")] +async fn pid_wait_ignores_an_empty_fixture_file() { + let directory = tempfile::tempdir().unwrap(); + let pid_file = directory.path().join("agent.pid"); + std::fs::write(&pid_file, "").unwrap(); + + let writer = async { + tokio::time::sleep(Duration::from_millis(25)).await; + std::fs::write(&pid_file, "42").unwrap(); + }; + let (pid, ()) = tokio::join!(wait_for_pid(&pid_file), writer); + + assert_eq!(pid, 42); } #[tokio::test(flavor = "current_thread")] @@ -98,12 +119,7 @@ async fn running_codex_process_survives_disconnect_and_reattach() { ACTION_DISPATCHER.send_request(PluginRequest::AgentNewSession { cwd: directory.path().to_path_buf(), }); - wait_for_file(&pid_file).await; - let original_pid = std::fs::read_to_string(&pid_file) - .unwrap() - .trim() - .parse::() - .unwrap(); + let original_pid = wait_for_pid(&pid_file).await; first .input(InputEvent::Key {