feat(agent): add built-in task plan tracking (#159)
* feat(agent): add built-in task plan tracking * fix(agent): preserve task plan contracts * fix(tui): preserve task plan rendering state
- commit
- 7a5217b9b355b358e39be3cf3afd25f5269963fd
- author
- bnema · · 2026-08-29 11:27 UTC
- committer
- GitHub · · 2026-08-29 11:27 UTC
- parent
- 025a25b
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
44 files · +732 -3700
M README.md +3 -3
| @@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 9 | 9 | ||||
| 10 | - one static binary. | 10 | - one static binary. | ||
| 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | ||
| 12 | - | - thirteen core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, update_goal, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries. | 12 | + | - fourteen core built-in tools (read, write, edit, bash, create_worktree, grep, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries. |
| 13 | - three run modes (interactive tui, print, json). | 13 | - three run modes (interactive tui, print, json). | ||
| 14 | - built-in telegram bot. | 14 | - built-in telegram bot. | ||
| 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | ||
| @@ -255,6 +255,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 255 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | 255 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | ||
| 256 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open`, `web_find`, and `web_click` respectively open a referenced page, find literal text in an opened page, and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | 256 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open`, `web_find`, and `web_click` respectively open a referenced page, find literal text in an opened page, and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | ||
| 257 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | 257 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, or mark the active goal `done` or `blocked`. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | ||
| 258 | + | - `update_plan`: maintain a turn-scoped checklist of `pending`, `in_progress`, and `completed` steps. Successful updates appear inline as an Updated Plan checklist and emit `plan_update` events in JSON and RPC modes. | |||
| 258 | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool. | 259 | - `schedule`: add, list, or cancel recurring five-field cron prompts for the current interactive session. A task captures the machine's current timezone when it is created. While this zut process remains open, a due task waits for an active target turn to finish, or resumes an inactive target session in the background; its output is recorded in that target transcript. Resumed background tasks also show a completion notification in the current TUI. Scheduling is in-memory only: quitting zut cancels pending tasks, and schedules do not survive restart or reboot. Print, stream, JSON, and RPC modes do not expose this tool. | ||
| 259 | 260 | ||||
| 260 | When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | 261 | When the sandbox is on (see `/jail`), filesystem tools, `grep`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | ||
| @@ -921,7 +922,6 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 921 | | `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. | | 922 | | `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. | | ||
| 922 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | 923 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | ||
| 923 | | `ctrl+d` | Exit on empty input. | | 924 | | `ctrl+d` | Exit on empty input. | | ||
| 924 | - | | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. | | |||
| 925 | | `ctrl+l` | Redraw the screen. | | 925 | | `ctrl+l` | Redraw the screen. | | ||
| 926 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | 926 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | ||
| 927 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). | | 927 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). | | ||
| @@ -1006,7 +1006,7 @@ An extension may ship only a theme: `extension.json` plus `theme.json` (or `them | |||||
| 1006 | 1006 | ||||
| 1007 | ### Reference | 1007 | ### Reference | ||
| 1008 | 1008 | ||||
| 1009 | - | `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell, including `tasked-phases` for spec-driven phase/checklist tracking. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap. | 1009 | + | `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol and the SDK API (`packages/agent/ext`). |
| 1010 | 1010 | ||||
| 1011 | ## Skills | 1011 | ## Skills | ||
| 1012 | 1012 | ||||
M docs/extensions.md +14 -32
| @@ -226,11 +226,11 @@ describing the tool's args (the same shape Anthropic and OpenAI accept). | |||||
| 226 | 226 | ||||
| 227 | Tool names live in the same namespace as built-in tools (`read`, | 227 | Tool names live in the same namespace as built-in tools (`read`, | ||
| 228 | `write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`, | 228 | `write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`, | ||
| 229 | - | `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `skill`). | 229 | + | `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`, |
| 230 | - | Conflicts with active built-ins are silently shadowed by the built-in. Every | 230 | + | `skill`). Conflicts with active built-ins are silently shadowed by the built-in. |
| 231 | - | native public-web capability name, plus `grep`, `schedule`, and `update_goal`, | 231 | + | Every native public-web capability name, plus `grep`, `schedule`, `update_goal`, |
| 232 | - | is reserved even when it is unavailable, so an extension cannot claim or | 232 | + | and `update_plan`, is reserved even when it is unavailable, so an extension |
| 233 | - | replace it. | 233 | + | cannot claim or replace it. |
| 234 | 234 | ||||
| 235 | Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`: | 235 | Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`: | ||
| 236 | 236 | ||||
| @@ -425,22 +425,14 @@ Sets or replaces one status item owned by the extension. Sending an empty | |||||
| 425 | 425 | ||||
| 426 | #### `widget` (one-way, persistent) | 426 | #### `widget` (one-way, persistent) | ||
| 427 | 427 | ||||
| 428 | - | Sets or replaces a compact widget. `position` is a host-defined placement | 428 | + | Sets or replaces a compact widget. Interactive zut renders widgets in the |
| 429 | - | hint with these interactive-zut values: | 429 | + | persistent chrome above the editor. Use `"above_input"` as the `position`; |
| 430 | - | 430 | + | empty and unknown positions resolve to the same placement. Use `open_panel` | |
| 431 | - | - `"above_input"` keeps the widget in the existing persistent chrome above | 431 | + | for interaction or navigation. |
| 432 | - | the editor. | |||
| 433 | - | - `"right_bar"` keeps the widget in a display-only side rail beside the | |||
| 434 | - | transcript when the terminal is wide enough. | |||
| 435 | - | ||||
| 436 | - | The host owns right-bar layout: it orders widgets, bounds width and height, | |||
| 437 | - | and truncates content. Narrow terminals and `Ctrl+B` use a bounded | |||
| 438 | - | `above_input` fallback. Empty or unknown positions keep the historical | |||
| 439 | - | `above_input` behavior. Use `open_panel` for interaction or navigation. | |||
| 440 | 432 | ||||
| 441 | ```json | 433 | ```json | ||
| 442 | - | {"type":"widget","id":"plan","position":"right_bar", | 434 | + | {"type":"widget","id":"progress","position":"above_input", |
| 443 | - | "title":"Tasked phases","lines":["Current phase: parse","[ ] validate inputs"]} | 435 | + | "title":"Progress","lines":["Indexing files","12/20 complete"]} |
| 444 | ``` | 436 | ``` | ||
| 445 | 437 | ||||
| 446 | #### `widget_clear` (one-way) | 438 | #### `widget_clear` (one-way) | ||
| @@ -722,12 +714,11 @@ latest persisted snapshot. A tool can return opaque state with | |||||
| 722 | request. `SetStatus`, `SetWidget`, and their clear methods update persistent | 714 | request. `SetStatus`, `SetWidget`, and their clear methods update persistent | ||
| 723 | interactive chrome without entering the transcript. | 715 | interactive chrome without entering the transcript. | ||
| 724 | 716 | ||||
| 725 | - | For Go extensions, use the SDK constants so the host can choose the responsive | 717 | + | For Go extensions, use the SDK placement constant: |
| 726 | - | layout without extension-specific terminal code: | |||
| 727 | 718 | ||||
| 728 | ```go | 719 | ```go | ||
| 729 | - | e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines) | 720 | + | e.SetWidget("progress", ext.WidgetPositionAboveInput, "Progress", lines) |
| 730 | - | e.ClearWidget("plan") | 721 | + | e.ClearWidget("progress") |
| 731 | ``` | 722 | ``` | ||
| 732 | 723 | ||||
| 733 | The SDK has four interceptor hooks, all optional: | 724 | The SDK has four interceptor hooks, all optional: | ||
| @@ -770,17 +761,8 @@ See: | |||||
| 770 | - `examples/extensions/guard/` — event subscriptions + tool-call | 761 | - `examples/extensions/guard/` — event subscriptions + tool-call | ||
| 771 | interception (refuses dangerous bash patterns) | 762 | interception (refuses dangerous bash patterns) | ||
| 772 | - `examples/extensions/todo/` — interactive persistent panel + tool | 763 | - `examples/extensions/todo/` — interactive persistent panel + tool | ||
| 773 | - | - `examples/extensions/tasked-phases/` — spec, phased checklist, persistent tool, and `/phases` panel | |||
| 774 | - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool | 764 | - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool | ||
| 775 | 765 | ||||
| 776 | - | The `tasked-phases` example bundles its companion skill under | |||
| 777 | - | `examples/extensions/tasked-phases/skills/` and declares it in | |||
| 778 | - | `extension.json`. Installing the extension therefore installs the workflow | |||
| 779 | - | skill automatically. The standalone copy at `examples/skills/tasked-phases/` | |||
| 780 | - | remains useful for project-local skill installation. Its state is restored per | |||
| 781 | - | session branch when zut session persistence is enabled; the extension keeps a | |||
| 782 | - | project-file fallback for hosts that do not persist sessions. | |||
| 783 | - | ||||
| 784 | ### Hot reload | 766 | ### Hot reload | ||
| 785 | 767 | ||||
| 786 | Type `/reload-ext` in the TUI to tear down every running extension | 768 | Type `/reload-ext` in the TUI to tear down every running extension | ||
M docs/rpc.md +2 -1
| @@ -204,6 +204,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||||
| 204 | | `tool_call` | `id`, `name`, `args` | The model proposed a tool call | | 204 | | `tool_call` | `id`, `name`, `args` | The model proposed a tool call | | ||
| 205 | | `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute | | 205 | | `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute | | ||
| 206 | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | 206 | | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs | | ||
| 207 | + | | `plan_update` | nullable `explanation`, `plan` | The built-in `update_plan` tool replaced the current turn checklist. Each plan item has `step` and `status`. | | |||
| 207 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | 208 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | ||
| 208 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | 209 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | ||
| 209 | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. | | 210 | | `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. | | ||
| @@ -212,7 +213,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||||
| 212 | | `error` | `message` | Non-fatal error message | | 213 | | `error` | `message` | Non-fatal error message | | ||
| 213 | | `compact_done` | `summary` | Compaction finished, summary text included | | 214 | | `compact_done` | `summary` | Compaction finished, summary text included | | ||
| 214 | | `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. | | 215 | | `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. | | ||
| 215 | - | | `ext_widget` | `extension`, `id`, `position`, `title`, `lines` | Persistent extension widget update. `position` may be `above_input` or `right_bar`; clients choose their own layout. | | 216 | + | | `ext_widget` | `extension`, `id`, `position`, `title`, `lines` | Persistent extension widget update. Interactive zut renders widgets above the input; RPC clients choose their own layout. | |
| 216 | | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. | | 217 | | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. | | ||
| 217 | | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. | | 218 | | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. | | ||
| 218 | 219 | ||||
M docs/skills.md +0 -1
| @@ -131,7 +131,6 @@ are omitted from the model's startup context. | |||||
| 131 | See `examples/skills/` for starter skills: | 131 | See `examples/skills/` for starter skills: | ||
| 132 | 132 | ||||
| 133 | - `code-review/` — self-review pass on a recent diff | 133 | - `code-review/` — self-review pass on a recent diff | ||
| 134 | - | - `tasked-phases/` — spec-to-phase checklist workflow for the `tasked-phases` extension and its `tasked_phases` tool | |||
| 135 | - `test-fix/` — diagnose + minimally fix a failing test | 134 | - `test-fix/` — diagnose + minimally fix a failing test | ||
| 136 | 135 | ||||
| 137 | ## Comparison to other discovery layouts | 136 | ## Comparison to other discovery layouts | ||
D examples/extensions/tasked-phases/README.md +0 -73
| @@ -1,73 +0,0 @@ | |||||
| 1 | - | # tasked-phases | |||
| 2 | - | ||||
| 3 | - | A zut extension for keeping a spec, phased plan, and checklist in one place. | |||
| 4 | - | It provides: | |||
| 5 | - | ||||
| 6 | - | - the `tasked_phases` tool for persistent spec/phase/task state; | |||
| 7 | - | - `/phases` for a full interactive panel; | |||
| 8 | - | - compact mutation results and full `get_status` output; | |||
| 9 | - | - project-scoped JSON persistence under the extension data directory. | |||
| 10 | - | ||||
| 11 | - | The tool actions are: | |||
| 12 | - | ||||
| 13 | - | ```text | |||
| 14 | - | get_status set_spec replace_plan | |||
| 15 | - | add_phase update_phase remove_phase | |||
| 16 | - | add_task update_task remove_task | |||
| 17 | - | set_current_phase set_task_checked set_phase_checked | |||
| 18 | - | clear | |||
| 19 | - | ``` | |||
| 20 | - | ||||
| 21 | - | Successful mutations return one progress line; use `get_status` or | |||
| 22 | - | `replace_plan` for details. `set_task_checked` changes one task; | |||
| 23 | - | `set_phase_checked` changes every task in a phase. | |||
| 24 | - | ||||
| 25 | - | ## Build and run | |||
| 26 | - | ||||
| 27 | - | From the zut repository: | |||
| 28 | - | ||||
| 29 | - | ```bash | |||
| 30 | - | cd examples/extensions/tasked-phases | |||
| 31 | - | go build -o tasked-phases . | |||
| 32 | - | ||||
| 33 | - | # One-run development load, launched from the project you want to track: | |||
| 34 | - | cd /path/to/project | |||
| 35 | - | zut --no-ext --ext /path/to/zut/examples/extensions/tasked-phases | |||
| 36 | - | ||||
| 37 | - | # Or install it globally (run this from the extension directory): | |||
| 38 | - | cd /path/to/zut/examples/extensions/tasked-phases | |||
| 39 | - | zut ext install --build=go . | |||
| 40 | - | ``` | |||
| 41 | - | ||||
| 42 | - | `--build=go` explicitly builds the manifest-declared executable in the staged | |||
| 43 | - | installation. If an incomplete copy was installed already, remove it with | |||
| 44 | - | `zut ext remove tasked-phases --yes` before reinstalling. You can also build | |||
| 45 | - | manually with `go build -o tasked-phases .` and then use `zut ext install .`. | |||
| 46 | - | ||||
| 47 | - | Then use `/phases` or ask the model to create a spec and phased checklist with | |||
| 48 | - | `tasked_phases`. The extension keys its durable state by the host-reported | |||
| 49 | - | project CWD. | |||
| 50 | - | ||||
| 51 | - | The extension bundles its workflow skill under `skills/tasked-phases/`, so | |||
| 52 | - | `zut ext install` makes the guidance available automatically. The standalone | |||
| 53 | - | copy at `examples/skills/tasked-phases/SKILL.md` remains useful when you want | |||
| 54 | - | the same workflow without installing the extension. | |||
| 55 | - | ||||
| 56 | - | ## Persistence and turn focus | |||
| 57 | - | ||||
| 58 | - | The extension stores one restrictive state file per project CWD as a fallback | |||
| 59 | - | for hosts that do not persist sessions. When zut session persistence is | |||
| 60 | - | available, the plan is also stored as extension-owned state on each session | |||
| 61 | - | branch and restored when sessions are opened, switched, or forked. | |||
| 62 | - | ||||
| 63 | - | Before each model turn, the extension supplies only the active phase, its goal, | |||
| 64 | - | and a bounded set of incomplete tasks as hidden context. The same context | |||
| 65 | - | reminds the model to update the checklist continuously, mark each task as soon | |||
| 66 | - | as it is completed, and advance the current phase when work moves forward. | |||
| 67 | - | Completed phases and future phases are not repeated in every provider request; | |||
| 68 | - | use `get_status` or `/phases` when the full checklist is needed. | |||
| 69 | - | ||||
| 70 | - | Active plans publish `p done/total | t done/total` and checklist rows through | |||
| 71 | - | `right_bar`. The host dims inactive phases, uses a bounded above-input fallback | |||
| 72 | - | on narrow terminals or when `Ctrl+B` hides the rail, and clears the chrome when | |||
| 73 | - | the plan is complete. Use `get_status` or `/phases` for full details. | |||
D examples/extensions/tasked-phases/extension.json +0 -10
| @@ -1,10 +0,0 @@ | |||||
| 1 | - | { | |||
| 2 | - | "name": "tasked-phases", | |||
| 3 | - | "version": "1.0.0", | |||
| 4 | - | "exec": "./tasked-phases", | |||
| 5 | - | "args": [], | |||
| 6 | - | "language": "go", | |||
| 7 | - | "description": "persistent phased spec and checklist tracking", | |||
| 8 | - | "skills": ["skills/tasked-phases"], | |||
| 9 | - | "enabled": true | |||
| 10 | - | } | |||
D examples/extensions/tasked-phases/main.go +0 -372
| @@ -1,372 +0,0 @@ | |||||
| 1 | - | package main | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "encoding/json" | |||
| 5 | - | "fmt" | |||
| 6 | - | "os" | |||
| 7 | - | "sync" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/zut/packages/agent/ext" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | const ( | |||
| 13 | - | extensionName = "tasked-phases" | |||
| 14 | - | extensionVersion = "1.0.0" | |||
| 15 | - | toolName = "tasked_phases" | |||
| 16 | - | panelID = "tasked-phases" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | const toolSchema = `{ | |||
| 20 | - | "type": "object", | |||
| 21 | - | "additionalProperties": false, | |||
| 22 | - | "properties": { | |||
| 23 | - | "action": { | |||
| 24 | - | "type": "string", | |||
| 25 | - | "enum": ["get_status", "set_spec", "replace_plan", "add_phase", "update_phase", "remove_phase", "add_task", "update_task", "remove_task", "set_current_phase", "set_task_checked", "set_phase_checked", "clear"], | |||
| 26 | - | "description": "State operation to perform. Closed plans must be restarted with clear, or set_spec immediately followed by replace_plan." | |||
| 27 | - | }, | |||
| 28 | - | "spec": {"type": "string", "description": "Spec text used by set_spec"}, | |||
| 29 | - | "phaseId": {"type": "string", "description": "Target phase id, raw id without the brackets shown in summaries"}, | |||
| 30 | - | "phaseTitle": {"type": "string", "description": "Phase title for add_phase or update_phase"}, | |||
| 31 | - | "phaseGoal": {"type": "string", "description": "Phase goal for add_phase or update_phase"}, | |||
| 32 | - | "taskId": {"type": "string", "description": "Target task id"}, | |||
| 33 | - | "taskText": {"type": "string", "description": "Task text for add_task or update_task"}, | |||
| 34 | - | "checked": {"type": "boolean", "description": "Checked state for set_task_checked or set_phase_checked"}, | |||
| 35 | - | "phases": { | |||
| 36 | - | "type": "array", | |||
| 37 | - | "description": "Full plan replacement used by replace_plan", | |||
| 38 | - | "items": { | |||
| 39 | - | "type": "object", | |||
| 40 | - | "additionalProperties": false, | |||
| 41 | - | "properties": { | |||
| 42 | - | "id": {"type": "string", "description": "Optional phase id. If omitted, one is generated."}, | |||
| 43 | - | "title": {"type": "string", "description": "Phase title"}, | |||
| 44 | - | "goal": {"type": "string", "description": "Optional short goal for the phase"}, | |||
| 45 | - | "tasks": { | |||
| 46 | - | "type": "array", | |||
| 47 | - | "description": "Checklist tasks for the phase", | |||
| 48 | - | "items": { | |||
| 49 | - | "type": "object", | |||
| 50 | - | "additionalProperties": false, | |||
| 51 | - | "properties": { | |||
| 52 | - | "id": {"type": "string", "description": "Optional task id. If omitted, one is generated."}, | |||
| 53 | - | "text": {"type": "string", "description": "Checklist task text"}, | |||
| 54 | - | "checked": {"type": "boolean", "description": "Whether the task starts checked"} | |||
| 55 | - | }, | |||
| 56 | - | "required": ["text"] | |||
| 57 | - | } | |||
| 58 | - | } | |||
| 59 | - | }, | |||
| 60 | - | "required": ["title"] | |||
| 61 | - | } | |||
| 62 | - | } | |||
| 63 | - | }, | |||
| 64 | - | "required": ["action"] | |||
| 65 | - | }` | |||
| 66 | - | ||||
| 67 | - | type app struct { | |||
| 68 | - | ext *ext.Extension | |||
| 69 | - | store *stateStore | |||
| 70 | - | ||||
| 71 | - | panelMu sync.Mutex | |||
| 72 | - | panelOpen bool | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func newApp(e *ext.Extension) *app { | |||
| 76 | - | return &app{ext: e, store: newStateStore()} | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | func main() { | |||
| 80 | - | e := ext.New(extensionName, extensionVersion) | |||
| 81 | - | a := newApp(e) | |||
| 82 | - | ||||
| 83 | - | e.OnHello(func(host ext.HostInfo) { | |||
| 84 | - | dataDir := host.DataDir | |||
| 85 | - | if dataDir == "" { | |||
| 86 | - | dataDir = host.ExtensionDir | |||
| 87 | - | } | |||
| 88 | - | if err := a.store.load(dataDir, host.CWD); err != nil { | |||
| 89 | - | e.Logf("load project state: %v", err) | |||
| 90 | - | a.publishUnavailableChrome() | |||
| 91 | - | return | |||
| 92 | - | } | |||
| 93 | - | if state, err := a.store.snapshot(); err == nil { | |||
| 94 | - | a.publishChrome(state) | |||
| 95 | - | } else { | |||
| 96 | - | e.Logf("snapshot project state: %v", err) | |||
| 97 | - | a.publishUnavailableChrome() | |||
| 98 | - | } | |||
| 99 | - | }) | |||
| 100 | - | restoreSession := func(event ext.Event) { | |||
| 101 | - | if err := a.store.activateSession(event.State); err != nil { | |||
| 102 | - | e.Logf("restore session state: %v", err) | |||
| 103 | - | a.publishUnavailableChrome() | |||
| 104 | - | return | |||
| 105 | - | } | |||
| 106 | - | if state, err := a.store.snapshot(); err == nil { | |||
| 107 | - | a.publishChrome(state) | |||
| 108 | - | a.renderPanelIfOpen(state) | |||
| 109 | - | } else { | |||
| 110 | - | e.Logf("restore session snapshot: %v", err) | |||
| 111 | - | a.publishUnavailableChrome() | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | e.On("session_opened", restoreSession) | |||
| 115 | - | e.On("session_switched", restoreSession) | |||
| 116 | - | e.On("session_forked", restoreSession) | |||
| 117 | - | e.InterceptTurnStart(func(_ int) ext.TurnStartDecision { | |||
| 118 | - | state, err := a.store.snapshot() | |||
| 119 | - | if err != nil { | |||
| 120 | - | e.Logf("load turn focus: %v", err) | |||
| 121 | - | return ext.TurnStartDecision{} | |||
| 122 | - | } | |||
| 123 | - | return ext.TurnStartDecision{Context: buildTurnContext(state)} | |||
| 124 | - | }) | |||
| 125 | - | ||||
| 126 | - | e.Tool(toolName, | |||
| 127 | - | "Persist and update a structured spec, phased plan, current phase, and checklist tasks. Keep the plan current throughout implementation: update it continuously, call set_task_checked immediately after each completed checklist task, and call set_current_phase when moving to another phase.", | |||
| 128 | - | json.RawMessage(toolSchema), a.handleTool) | |||
| 129 | - | e.Command("phases", "Show the current spec, phases, and checklist state", a.handleCommand) | |||
| 130 | - | e.OnPanelKey(panelID, a.handlePanelKey, a.handlePanelClose) | |||
| 131 | - | ||||
| 132 | - | if err := e.Run(); err != nil { | |||
| 133 | - | fmt.Fprintln(os.Stderr, err) | |||
| 134 | - | os.Exit(1) | |||
| 135 | - | } | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | func (a *app) handleCommand(_ string) ext.Response { | |||
| 139 | - | state, err := a.store.snapshot() | |||
| 140 | - | if err != nil { | |||
| 141 | - | a.publishUnavailableChrome() | |||
| 142 | - | return ext.Errorf("load tasked phases: %v", err) | |||
| 143 | - | } | |||
| 144 | - | a.publishChrome(state) | |||
| 145 | - | a.panelMu.Lock() | |||
| 146 | - | a.panelOpen = true | |||
| 147 | - | a.panelMu.Unlock() | |||
| 148 | - | return ext.OpenPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel") | |||
| 149 | - | } | |||
| 150 | - | ||||
| 151 | - | func (a *app) handlePanelKey(key, _ string) { | |||
| 152 | - | if key == "esc" { | |||
| 153 | - | a.ext.ClosePanel(panelID) | |||
| 154 | - | a.setPanelOpen(false) | |||
| 155 | - | } | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func (a *app) handlePanelClose() { a.setPanelOpen(false) } | |||
| 159 | - | ||||
| 160 | - | func (a *app) setPanelOpen(open bool) { | |||
| 161 | - | a.panelMu.Lock() | |||
| 162 | - | a.panelOpen = open | |||
| 163 | - | a.panelMu.Unlock() | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | func (a *app) renderPanelIfOpen(state PlanState) { | |||
| 167 | - | a.panelMu.Lock() | |||
| 168 | - | open := a.panelOpen | |||
| 169 | - | a.panelMu.Unlock() | |||
| 170 | - | if open { | |||
| 171 | - | a.ext.RenderPanel(panelID, "Tasked phases", buildViewLines(state), "Escape closes this panel") | |||
| 172 | - | } | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | func (a *app) handleTool(args json.RawMessage) ext.ToolResult { | |||
| 176 | - | var params toolArgs | |||
| 177 | - | if err := json.Unmarshal(args, ¶ms); err != nil { | |||
| 178 | - | return ext.TextErrorResult("invalid args: " + err.Error()) | |||
| 179 | - | } | |||
| 180 | - | if !isKnownAction(params.Action) { | |||
| 181 | - | return ext.TextErrorResult("unsupported action: " + params.Action) | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | if params.Action == actionGetStatus { | |||
| 185 | - | state, err := a.store.snapshot() | |||
| 186 | - | if err != nil { | |||
| 187 | - | a.publishUnavailableChrome() | |||
| 188 | - | return toolResult(params.Action, PlanState{}, "", err) | |||
| 189 | - | } | |||
| 190 | - | a.publishChrome(state) | |||
| 191 | - | a.renderPanelIfOpen(state) | |||
| 192 | - | return toolResult(params.Action, state, "Current phased plan status", nil) | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | state, headline, err := a.store.transact(func(current PlanState) (PlanState, string, error) { | |||
| 196 | - | return applyAction(current, params) | |||
| 197 | - | }) | |||
| 198 | - | // A zero state means transact could not obtain a loaded snapshot. Do not | |||
| 199 | - | // replace persistent chrome with an invented empty plan; clear it instead. | |||
| 200 | - | // For action and persistence errors, transact returns the unchanged usable | |||
| 201 | - | // state, which should remain visible to help the user recover. | |||
| 202 | - | if err == nil || state.Version != 0 { | |||
| 203 | - | a.publishChrome(state) | |||
| 204 | - | } else { | |||
| 205 | - | a.publishUnavailableChrome() | |||
| 206 | - | } | |||
| 207 | - | if err == nil { | |||
| 208 | - | a.renderPanelIfOpen(state) | |||
| 209 | - | } | |||
| 210 | - | return toolResult(params.Action, state, headline, err) | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | func (a *app) publishUnavailableChrome() { | |||
| 214 | - | clearChrome(a.ext) | |||
| 215 | - | a.panelMu.Lock() | |||
| 216 | - | open := a.panelOpen | |||
| 217 | - | a.panelMu.Unlock() | |||
| 218 | - | if open { | |||
| 219 | - | a.ext.RenderPanel(panelID, "Tasked phases", []string{"Plan state could not be restored for this session."}, "Escape closes this panel") | |||
| 220 | - | } | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | type chromeContent struct { | |||
| 224 | - | summary string | |||
| 225 | - | lines []string | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | const ( | |||
| 229 | - | chromePhaseMaxChars = 32 | |||
| 230 | - | chromeTaskMaxChars = 64 | |||
| 231 | - | ) | |||
| 232 | - | ||||
| 233 | - | func buildChrome(state PlanState) (chromeContent, bool) { | |||
| 234 | - | // A spec-only state is still a plan in progress. Show its compact summary | |||
| 235 | - | // so the right bar opens as soon as planning starts, without exposing the | |||
| 236 | - | // spec text in persistent chrome. | |||
| 237 | - | if !hasStoredPlan(state) || isPlanComplete(state) || isPlanClosed(state) { | |||
| 238 | - | return chromeContent{}, false | |||
| 239 | - | } | |||
| 240 | - | return chromeContent{ | |||
| 241 | - | summary: buildChromeSummary(state), | |||
| 242 | - | lines: buildChecklistLines(state), | |||
| 243 | - | }, true | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | func buildChromeSummary(state PlanState) string { | |||
| 247 | - | phaseDone := 0 | |||
| 248 | - | for _, phase := range state.Phases { | |||
| 249 | - | if isPhaseDone(phase) { | |||
| 250 | - | phaseDone++ | |||
| 251 | - | } | |||
| 252 | - | } | |||
| 253 | - | taskDone, taskTotal := getPlanProgress(state) | |||
| 254 | - | return fmt.Sprintf("p %d/%d | t %d/%d", phaseDone, len(state.Phases), taskDone, taskTotal) | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | func chromeLabel(value, fallback string, maxLength int) string { | |||
| 258 | - | value = truncatePlain(displaySingleLine(value), maxLength) | |||
| 259 | - | if value == "" { | |||
| 260 | - | return fallback | |||
| 261 | - | } | |||
| 262 | - | return value | |||
| 263 | - | } | |||
| 264 | - | ||||
| 265 | - | func getChromePhase(state PlanState) *Phase { | |||
| 266 | - | if current := getCurrentPhase(state); current != nil && !isPhaseDone(*current) { | |||
| 267 | - | return current | |||
| 268 | - | } | |||
| 269 | - | for i := range state.Phases { | |||
| 270 | - | if !isPhaseDone(state.Phases[i]) { | |||
| 271 | - | return &state.Phases[i] | |||
| 272 | - | } | |||
| 273 | - | } | |||
| 274 | - | if len(state.Phases) == 0 { | |||
| 275 | - | return nil | |||
| 276 | - | } | |||
| 277 | - | return &state.Phases[len(state.Phases)-1] | |||
| 278 | - | } | |||
| 279 | - | ||||
| 280 | - | func getChromeTask(phase Phase) *PhaseTask { | |||
| 281 | - | for i := range phase.Tasks { | |||
| 282 | - | if !phase.Tasks[i].Checked { | |||
| 283 | - | return &phase.Tasks[i] | |||
| 284 | - | } | |||
| 285 | - | } | |||
| 286 | - | return nil | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | func buildChecklistLines(state PlanState) []string { | |||
| 290 | - | activePhase := getChromePhase(state) | |||
| 291 | - | activeTaskID := "" | |||
| 292 | - | if activePhase != nil { | |||
| 293 | - | if activeTask := getChromeTask(*activePhase); activeTask != nil { | |||
| 294 | - | activeTaskID = activeTask.ID | |||
| 295 | - | } | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | lines := make([]string, 0) | |||
| 299 | - | for phaseIndex, phase := range state.Phases { | |||
| 300 | - | if phaseIndex > 0 { | |||
| 301 | - | lines = append(lines, "") | |||
| 302 | - | } | |||
| 303 | - | phaseDone, phaseTotal := getPhaseProgress(phase) | |||
| 304 | - | phaseMarker := "[ ]" | |||
| 305 | - | if isPhaseDone(phase) { | |||
| 306 | - | phaseMarker = "[x]" | |||
| 307 | - | } else if activePhase != nil && phase.ID == activePhase.ID { | |||
| 308 | - | phaseMarker = "[>]" | |||
| 309 | - | } | |||
| 310 | - | lines = append(lines, fmt.Sprintf("%s %s %d/%d", phaseMarker, chromeLabel(formatPhaseTitle(phase), "untitled phase", chromePhaseMaxChars), phaseDone, phaseTotal)) | |||
| 311 | - | for _, task := range phase.Tasks { | |||
| 312 | - | taskMarker := "[ ]" | |||
| 313 | - | if task.Checked { | |||
| 314 | - | taskMarker = "[x]" | |||
| 315 | - | } else if activePhase != nil && phase.ID == activePhase.ID && task.ID == activeTaskID { | |||
| 316 | - | taskMarker = "[>]" | |||
| 317 | - | } | |||
| 318 | - | lines = append(lines, fmt.Sprintf(" %s %s", taskMarker, chromeLabel(formatTaskText(task), "untitled task", chromeTaskMaxChars))) | |||
| 319 | - | } | |||
| 320 | - | } | |||
| 321 | - | if len(lines) == 0 { | |||
| 322 | - | lines = append(lines, "No phases yet") | |||
| 323 | - | } | |||
| 324 | - | return lines | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | type chromeHost interface { | |||
| 328 | - | SetStatus(key, text string) | |||
| 329 | - | ClearStatus(key string) | |||
| 330 | - | SetWidget(id, position, title string, lines []string) | |||
| 331 | - | ClearWidget(id string) | |||
| 332 | - | } | |||
| 333 | - | ||||
| 334 | - | func clearChrome(host chromeHost) { | |||
| 335 | - | host.ClearStatus("progress") | |||
| 336 | - | host.ClearWidget("plan") | |||
| 337 | - | } | |||
| 338 | - | ||||
| 339 | - | func applyChrome(host chromeHost, state PlanState) { | |||
| 340 | - | chrome, ok := buildChrome(state) | |||
| 341 | - | if !ok { | |||
| 342 | - | clearChrome(host) | |||
| 343 | - | return | |||
| 344 | - | } | |||
| 345 | - | host.SetStatus("progress", chrome.summary) | |||
| 346 | - | host.SetWidget("plan", ext.WidgetPositionRightBar, chrome.summary, chrome.lines) | |||
| 347 | - | } | |||
| 348 | - | ||||
| 349 | - | func (a *app) publishChrome(state PlanState) { | |||
| 350 | - | applyChrome(a.ext, state) | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | func toolResult(action string, state PlanState, headline string, err error) ext.ToolResult { | |||
| 354 | - | text := buildToolResultText(action, state, headline, err) | |||
| 355 | - | var result ext.ToolResult | |||
| 356 | - | if err != nil { | |||
| 357 | - | result = ext.TextErrorResult(text) | |||
| 358 | - | } else { | |||
| 359 | - | result = ext.TextResult(text) | |||
| 360 | - | } | |||
| 361 | - | result.Details = ext.JSONDetails(state) | |||
| 362 | - | return result | |||
| 363 | - | } | |||
| 364 | - | ||||
| 365 | - | func isKnownAction(action string) bool { | |||
| 366 | - | for _, known := range toolActions { | |||
| 367 | - | if action == known { | |||
| 368 | - | return true | |||
| 369 | - | } | |||
| 370 | - | } | |||
| 371 | - | return false | |||
| 372 | - | } | |||
D examples/extensions/tasked-phases/main_test.go +0 -212
| @@ -1,212 +0,0 @@ | |||||
| 1 | - | package main | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strings" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/zut/packages/agent/ext" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestBuildChromeHidesEmptyPlan(t *testing.T) { | |||
| 11 | - | chrome, visible := buildChrome(createEmptyState()) | |||
| 12 | - | if visible { | |||
| 13 | - | t.Fatal("empty plan should not publish persistent chrome") | |||
| 14 | - | } | |||
| 15 | - | if chrome.summary != "" || len(chrome.lines) != 0 { | |||
| 16 | - | t.Fatalf("empty chrome = %#v, want zero content", chrome) | |||
| 17 | - | } | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func TestBuildChromeUsesCompactSummaryWithoutSpecText(t *testing.T) { | |||
| 21 | - | state := PlanState{ | |||
| 22 | - | Version: 1, | |||
| 23 | - | Spec: "private spec text must not be persistent chrome", | |||
| 24 | - | Phases: []Phase{ | |||
| 25 | - | { | |||
| 26 | - | ID: "phase-1", | |||
| 27 | - | Title: "Finished discovery", | |||
| 28 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Completed discovery", Checked: true}}, | |||
| 29 | - | }, | |||
| 30 | - | { | |||
| 31 | - | ID: "phase-2", | |||
| 32 | - | Title: "Implement compact output", | |||
| 33 | - | Tasks: []PhaseTask{ | |||
| 34 | - | {ID: "task-2", Text: "Completed implementation", Checked: true}, | |||
| 35 | - | {ID: "task-3", Text: "Remaining implementation task"}, | |||
| 36 | - | }, | |||
| 37 | - | }, | |||
| 38 | - | }, | |||
| 39 | - | CurrentPhaseID: "phase-2", | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | chrome, visible := buildChrome(state) | |||
| 43 | - | if !visible { | |||
| 44 | - | t.Fatal("stored plan should publish persistent chrome") | |||
| 45 | - | } | |||
| 46 | - | if want := "p 1/2 | t 2/3"; chrome.summary != want { | |||
| 47 | - | t.Fatalf("chrome summary = %q, want %q", chrome.summary, want) | |||
| 48 | - | } | |||
| 49 | - | if strings.Contains(chrome.summary, state.Spec) || strings.Contains(chrome.summary, state.Phases[1].Title) { | |||
| 50 | - | t.Fatalf("compact summary leaked plan text: %q", chrome.summary) | |||
| 51 | - | } | |||
| 52 | - | joined := strings.Join(chrome.lines, "\n") | |||
| 53 | - | for _, want := range []string{ | |||
| 54 | - | "[x] Finished discovery 1/1", | |||
| 55 | - | " [x] Completed discovery", | |||
| 56 | - | "[>] Implement compact output 1/2", | |||
| 57 | - | " [>] Remaining implementation task", | |||
| 58 | - | } { | |||
| 59 | - | if !strings.Contains(joined, want) { | |||
| 60 | - | t.Fatalf("checklist missing %q: %s", want, joined) | |||
| 61 | - | } | |||
| 62 | - | } | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | func TestBuildChromeHidesCompletePlan(t *testing.T) { | |||
| 66 | - | closedAt := int64(1) | |||
| 67 | - | state := PlanState{ | |||
| 68 | - | Version: 1, | |||
| 69 | - | Phases: []Phase{{ | |||
| 70 | - | ID: "phase-1", | |||
| 71 | - | Title: "Ship", | |||
| 72 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Done", Checked: true}}, | |||
| 73 | - | }}, | |||
| 74 | - | ClosedAt: &closedAt, | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | if got, want := buildChromeSummary(state), "p 1/1 | t 1/1"; got != want { | |||
| 78 | - | t.Fatalf("closed chrome summary = %q, want %q", got, want) | |||
| 79 | - | } | |||
| 80 | - | if _, visible := buildChrome(state); visible { | |||
| 81 | - | t.Fatal("complete plan should not publish persistent chrome") | |||
| 82 | - | } | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | func TestBuildChromeHidesCompletePlanBeforeCloseMetadata(t *testing.T) { | |||
| 86 | - | state := PlanState{ | |||
| 87 | - | Version: 1, | |||
| 88 | - | Spec: "complete plan", | |||
| 89 | - | Phases: []Phase{{ | |||
| 90 | - | ID: "phase-1", | |||
| 91 | - | Title: "Ship", | |||
| 92 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Done", Checked: true}}, | |||
| 93 | - | }}, | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | if _, visible := buildChrome(state); visible { | |||
| 97 | - | t.Fatal("complete plan without close metadata should not publish persistent chrome") | |||
| 98 | - | } | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | type chromeRecorder struct { | |||
| 102 | - | statusSet int | |||
| 103 | - | statusClear int | |||
| 104 | - | statusTexts []string | |||
| 105 | - | widgetSet int | |||
| 106 | - | widgetClear int | |||
| 107 | - | widgetPositions []string | |||
| 108 | - | widgetTitles []string | |||
| 109 | - | widgetLines [][]string | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | func (r *chromeRecorder) SetStatus(_, text string) { | |||
| 113 | - | r.statusSet++ | |||
| 114 | - | r.statusTexts = append(r.statusTexts, text) | |||
| 115 | - | } | |||
| 116 | - | func (r *chromeRecorder) ClearStatus(string) { r.statusClear++ } | |||
| 117 | - | func (r *chromeRecorder) SetWidget(_, position, title string, lines []string) { | |||
| 118 | - | r.widgetSet++ | |||
| 119 | - | r.widgetPositions = append(r.widgetPositions, position) | |||
| 120 | - | r.widgetTitles = append(r.widgetTitles, title) | |||
| 121 | - | r.widgetLines = append(r.widgetLines, append([]string(nil), lines...)) | |||
| 122 | - | } | |||
| 123 | - | func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ } | |||
| 124 | - | ||||
| 125 | - | func TestApplyChromePublishesCompactStatusAndTitle(t *testing.T) { | |||
| 126 | - | recorder := &chromeRecorder{} | |||
| 127 | - | state := PlanState{ | |||
| 128 | - | Version: 1, | |||
| 129 | - | Spec: "must not be shown", | |||
| 130 | - | Phases: []Phase{ | |||
| 131 | - | { | |||
| 132 | - | ID: "phase-1", | |||
| 133 | - | Title: "Finished", | |||
| 134 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Ship", Checked: true}}, | |||
| 135 | - | }, | |||
| 136 | - | { | |||
| 137 | - | ID: "phase-2", | |||
| 138 | - | Title: "Pending", | |||
| 139 | - | Tasks: []PhaseTask{{ID: "task-2", Text: "Review"}}, | |||
| 140 | - | }, | |||
| 141 | - | }, | |||
| 142 | - | } | |||
| 143 | - | applyChrome(recorder, state) | |||
| 144 | - | ||||
| 145 | - | if recorder.widgetSet != 1 || len(recorder.widgetPositions) != 1 || recorder.widgetPositions[0] != ext.WidgetPositionRightBar { | |||
| 146 | - | t.Fatalf("widget placement = %v, want %q", recorder.widgetPositions, ext.WidgetPositionRightBar) | |||
| 147 | - | } | |||
| 148 | - | want := buildChromeSummary(state) | |||
| 149 | - | if len(recorder.statusTexts) != 1 || recorder.statusTexts[0] != want { | |||
| 150 | - | t.Fatalf("status updates = %v, want %q", recorder.statusTexts, want) | |||
| 151 | - | } | |||
| 152 | - | if len(recorder.widgetTitles) != 1 || recorder.widgetTitles[0] != want { | |||
| 153 | - | t.Fatalf("widget titles = %v, want %q", recorder.widgetTitles, want) | |||
| 154 | - | } | |||
| 155 | - | if len(recorder.widgetLines) != 1 || len(recorder.widgetLines[0]) == 0 { | |||
| 156 | - | t.Fatalf("widget lines = %v, want checklist rows", recorder.widgetLines) | |||
| 157 | - | } | |||
| 158 | - | if !strings.Contains(strings.Join(recorder.widgetLines[0], "\n"), "[>] Review") { | |||
| 159 | - | t.Fatalf("active task is missing from widget lines: %v", recorder.widgetLines[0]) | |||
| 160 | - | } | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | func TestApplyChromeClearsEmptyPlan(t *testing.T) { | |||
| 164 | - | recorder := &chromeRecorder{} | |||
| 165 | - | applyChrome(recorder, createEmptyState()) | |||
| 166 | - | ||||
| 167 | - | if recorder.statusClear != 1 || recorder.widgetClear != 1 { | |||
| 168 | - | t.Fatalf("empty plan clear operations = status %d, widget %d; want one each", recorder.statusClear, recorder.widgetClear) | |||
| 169 | - | } | |||
| 170 | - | if recorder.statusSet != 0 || recorder.widgetSet != 0 { | |||
| 171 | - | t.Fatalf("empty plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet) | |||
| 172 | - | } | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | func TestApplyChromeClearsCompletedPlan(t *testing.T) { | |||
| 176 | - | recorder := &chromeRecorder{} | |||
| 177 | - | state := PlanState{ | |||
| 178 | - | Version: 1, | |||
| 179 | - | Phases: []Phase{{ | |||
| 180 | - | ID: "phase-1", | |||
| 181 | - | Title: "Ship", | |||
| 182 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Done", Checked: true}}, | |||
| 183 | - | }}, | |||
| 184 | - | } | |||
| 185 | - | applyChrome(recorder, state) | |||
| 186 | - | ||||
| 187 | - | if recorder.statusClear != 1 || recorder.widgetClear != 1 { | |||
| 188 | - | t.Fatalf("complete plan clear operations = status %d, widget %d; want one each", recorder.statusClear, recorder.widgetClear) | |||
| 189 | - | } | |||
| 190 | - | if recorder.statusSet != 0 || recorder.widgetSet != 0 { | |||
| 191 | - | t.Fatalf("complete plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet) | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | func TestApplyChromeShowsEmptySummaryWithoutSpecText(t *testing.T) { | |||
| 196 | - | recorder := &chromeRecorder{} | |||
| 197 | - | state := PlanState{Version: 1, Spec: "spec only"} | |||
| 198 | - | applyChrome(recorder, state) | |||
| 199 | - | ||||
| 200 | - | if recorder.statusClear != 0 || recorder.widgetClear != 0 || recorder.statusSet != 1 || recorder.widgetSet != 1 { | |||
| 201 | - | t.Fatalf("spec-only chrome lifecycle = %#v, want a compact update", recorder) | |||
| 202 | - | } | |||
| 203 | - | if got, want := recorder.statusTexts[0], "p 0/0 | t 0/0"; got != want { | |||
| 204 | - | t.Fatalf("spec-only status = %q, want %q", got, want) | |||
| 205 | - | } | |||
| 206 | - | if got, want := recorder.widgetTitles[0], "p 0/0 | t 0/0"; got != want { | |||
| 207 | - | t.Fatalf("spec-only widget title = %q, want %q", got, want) | |||
| 208 | - | } | |||
| 209 | - | if len(recorder.widgetLines[0]) != 1 || recorder.widgetLines[0][0] != "No phases yet" { | |||
| 210 | - | t.Fatalf("spec-only widget lines = %v, want empty-plan hint", recorder.widgetLines[0]) | |||
| 211 | - | } | |||
| 212 | - | } | |||
D examples/extensions/tasked-phases/manifest_test.go +0 -48
| @@ -1,48 +0,0 @@ | |||||
| 1 | - | package main | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "encoding/json" | |||
| 5 | - | "os" | |||
| 6 | - | "path/filepath" | |||
| 7 | - | "slices" | |||
| 8 | - | "testing" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | func TestManifestMatchesExtensionConstants(t *testing.T) { | |||
| 12 | - | data, err := os.ReadFile("extension.json") | |||
| 13 | - | if err != nil { | |||
| 14 | - | t.Fatal(err) | |||
| 15 | - | } | |||
| 16 | - | var manifest struct { | |||
| 17 | - | Name string `json:"name"` | |||
| 18 | - | Version string `json:"version"` | |||
| 19 | - | Skills []string `json:"skills"` | |||
| 20 | - | } | |||
| 21 | - | if err := json.Unmarshal(data, &manifest); err != nil { | |||
| 22 | - | t.Fatalf("extension.json is not valid JSON: %v", err) | |||
| 23 | - | } | |||
| 24 | - | if manifest.Name != extensionName || manifest.Version != extensionVersion { | |||
| 25 | - | t.Fatalf("manifest = %s/%s, constants = %s/%s", manifest.Name, manifest.Version, extensionName, extensionVersion) | |||
| 26 | - | } | |||
| 27 | - | for _, skill := range manifest.Skills { | |||
| 28 | - | if _, err := os.Stat(filepath.Join(skill, "SKILL.md")); err != nil { | |||
| 29 | - | t.Errorf("declared skill %q has no SKILL.md: %v", skill, err) | |||
| 30 | - | } | |||
| 31 | - | } | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | func TestToolSchemaEnumMatchesToolActions(t *testing.T) { | |||
| 35 | - | var schema struct { | |||
| 36 | - | Properties struct { | |||
| 37 | - | Action struct { | |||
| 38 | - | Enum []string `json:"enum"` | |||
| 39 | - | } `json:"action"` | |||
| 40 | - | } `json:"properties"` | |||
| 41 | - | } | |||
| 42 | - | if err := json.Unmarshal([]byte(toolSchema), &schema); err != nil { | |||
| 43 | - | t.Fatalf("tool schema is not valid JSON: %v", err) | |||
| 44 | - | } | |||
| 45 | - | if !slices.Equal(schema.Properties.Action.Enum, toolActions) { | |||
| 46 | - | t.Fatalf("schema enum = %v, toolActions = %v", schema.Properties.Action.Enum, toolActions) | |||
| 47 | - | } | |||
| 48 | - | } | |||
D examples/extensions/tasked-phases/skill_sync_test.go +0 -24
| @@ -1,24 +0,0 @@ | |||||
| 1 | - | package main | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "os" | |||
| 6 | - | "path/filepath" | |||
| 7 | - | "testing" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestBundledSkillMatchesStandaloneCopy(t *testing.T) { | |||
| 11 | - | extensionSkill := filepath.Join("skills", "tasked-phases", "SKILL.md") | |||
| 12 | - | standaloneSkill := filepath.Join("..", "..", "skills", "tasked-phases", "SKILL.md") | |||
| 13 | - | extension, err := os.ReadFile(extensionSkill) | |||
| 14 | - | if err != nil { | |||
| 15 | - | t.Fatal(err) | |||
| 16 | - | } | |||
| 17 | - | standalone, err := os.ReadFile(standaloneSkill) | |||
| 18 | - | if err != nil { | |||
| 19 | - | t.Fatal(err) | |||
| 20 | - | } | |||
| 21 | - | if !bytes.Equal(extension, standalone) { | |||
| 22 | - | t.Fatal("bundled and standalone tasked-phases skills have diverged") | |||
| 23 | - | } | |||
| 24 | - | } | |||
D examples/extensions/tasked-phases/skills/tasked-phases/SKILL.md +0 -37
| @@ -1,37 +0,0 @@ | |||||
| 1 | - | --- | |||
| 2 | - | name: tasked-phases | |||
| 3 | - | description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase. | |||
| 4 | - | --- | |||
| 5 | - | ||||
| 6 | - | # Tasked Phases | |||
| 7 | - | ||||
| 8 | - | Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress. | |||
| 9 | - | ||||
| 10 | - | ## Workflow | |||
| 11 | - | ||||
| 12 | - | 1. Clarify missing requirements when the spec is ambiguous. | |||
| 13 | - | 2. Save the accepted spec with `tasked_phases` using `set_spec`. | |||
| 14 | - | 3. Create or materially revise the plan with `replace_plan`. | |||
| 15 | - | 4. Keep phases small and tasks concrete enough to check off. | |||
| 16 | - | 5. Set the focus with `set_current_phase` and pass the target `phaseId`. | |||
| 17 | - | 6. Call `set_task_checked` immediately after each checklist task is completed. | |||
| 18 | - | 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task. | |||
| 19 | - | 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases. | |||
| 20 | - | ||||
| 21 | - | Successful mutations return one progress line; use `get_status` or | |||
| 22 | - | `replace_plan` for details. `set_task_checked` changes one task; | |||
| 23 | - | `set_phase_checked` changes every task in a phase. | |||
| 24 | - | ||||
| 25 | - | ## Planning rules | |||
| 26 | - | ||||
| 27 | - | - Prefer 3–7 phases unless the task is tiny. | |||
| 28 | - | - Each task should describe an observable outcome. | |||
| 29 | - | - Avoid vague tasks such as “work on this” or “finish implementation”. | |||
| 30 | - | - Keep the stored spec and plan concise and operational. | |||
| 31 | - | ||||
| 32 | - | ## Tool guidance | |||
| 33 | - | ||||
| 34 | - | - Use `get_status` before relying on remembered state or revising a long-running plan. | |||
| 35 | - | - Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes. | |||
| 36 | - | - Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output. | |||
| 37 | - | - Do not rely on prose alone for completion state; update the tool after each completed task. | |||
D examples/extensions/tasked-phases/state.go +0 -1145
| @@ -1,1145 +0,0 @@ | |||||
| 1 | - | package main | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "crypto/sha256" | |||
| 5 | - | "encoding/hex" | |||
| 6 | - | "encoding/json" | |||
| 7 | - | "errors" | |||
| 8 | - | "fmt" | |||
| 9 | - | "os" | |||
| 10 | - | "path/filepath" | |||
| 11 | - | "regexp" | |||
| 12 | - | "strconv" | |||
| 13 | - | "strings" | |||
| 14 | - | "sync" | |||
| 15 | - | "time" | |||
| 16 | - | "unicode" | |||
| 17 | - | "unicode/utf8" | |||
| 18 | - | ) | |||
| 19 | - | ||||
| 20 | - | const ( | |||
| 21 | - | actionGetStatus = "get_status" | |||
| 22 | - | actionSetSpec = "set_spec" | |||
| 23 | - | actionReplacePlan = "replace_plan" | |||
| 24 | - | actionAddPhase = "add_phase" | |||
| 25 | - | actionUpdatePhase = "update_phase" | |||
| 26 | - | actionRemovePhase = "remove_phase" | |||
| 27 | - | actionAddTask = "add_task" | |||
| 28 | - | actionUpdateTask = "update_task" | |||
| 29 | - | actionRemoveTask = "remove_task" | |||
| 30 | - | actionSetCurrentPhase = "set_current_phase" | |||
| 31 | - | actionSetTaskChecked = "set_task_checked" | |||
| 32 | - | actionSetPhaseChecked = "set_phase_checked" | |||
| 33 | - | actionClear = "clear" | |||
| 34 | - | ) | |||
| 35 | - | ||||
| 36 | - | var ( | |||
| 37 | - | phaseIDPattern = regexp.MustCompile(`^phase-(\d+)$`) | |||
| 38 | - | taskIDPattern = regexp.MustCompile(`^task-(\d+)$`) | |||
| 39 | - | bracketedIDPattern = regexp.MustCompile(`\[([^\]]+)\]`) | |||
| 40 | - | ) | |||
| 41 | - | ||||
| 42 | - | var toolActions = []string{ | |||
| 43 | - | actionGetStatus, | |||
| 44 | - | actionSetSpec, | |||
| 45 | - | actionReplacePlan, | |||
| 46 | - | actionAddPhase, | |||
| 47 | - | actionUpdatePhase, | |||
| 48 | - | actionRemovePhase, | |||
| 49 | - | actionAddTask, | |||
| 50 | - | actionUpdateTask, | |||
| 51 | - | actionRemoveTask, | |||
| 52 | - | actionSetCurrentPhase, | |||
| 53 | - | actionSetTaskChecked, | |||
| 54 | - | actionSetPhaseChecked, | |||
| 55 | - | actionClear, | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | var closedPlanBlockedActions = map[string]bool{ | |||
| 59 | - | actionReplacePlan: true, | |||
| 60 | - | actionAddPhase: true, | |||
| 61 | - | actionUpdatePhase: true, | |||
| 62 | - | actionRemovePhase: true, | |||
| 63 | - | actionAddTask: true, | |||
| 64 | - | actionUpdateTask: true, | |||
| 65 | - | actionRemoveTask: true, | |||
| 66 | - | actionSetCurrentPhase: true, | |||
| 67 | - | actionSetTaskChecked: true, | |||
| 68 | - | actionSetPhaseChecked: true, | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | type PhaseTask struct { | |||
| 72 | - | ID string `json:"id"` | |||
| 73 | - | Text string `json:"text"` | |||
| 74 | - | Checked bool `json:"checked"` | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | type Phase struct { | |||
| 78 | - | ID string `json:"id"` | |||
| 79 | - | Title string `json:"title"` | |||
| 80 | - | Goal string `json:"goal,omitempty"` | |||
| 81 | - | Tasks []PhaseTask `json:"tasks"` | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | type PlanState struct { | |||
| 85 | - | Version int `json:"version"` | |||
| 86 | - | Spec string `json:"spec,omitempty"` | |||
| 87 | - | Phases []Phase `json:"phases"` | |||
| 88 | - | CurrentPhaseID string `json:"currentPhaseId,omitempty"` | |||
| 89 | - | ClosedAt *int64 `json:"closedAt,omitempty"` | |||
| 90 | - | ClosedSummary string `json:"closedSummary,omitempty"` | |||
| 91 | - | NextPhaseNumber int `json:"nextPhaseNumber"` | |||
| 92 | - | NextTaskNumber int `json:"nextTaskNumber"` | |||
| 93 | - | UpdatedAt int64 `json:"updatedAt"` | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | type TaskInput struct { | |||
| 97 | - | ID *string `json:"id"` | |||
| 98 | - | Text string `json:"text"` | |||
| 99 | - | Checked *bool `json:"checked"` | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | type PhaseInput struct { | |||
| 103 | - | ID *string `json:"id"` | |||
| 104 | - | Title string `json:"title"` | |||
| 105 | - | Goal *string `json:"goal"` | |||
| 106 | - | Tasks *[]TaskInput `json:"tasks"` | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | type toolArgs struct { | |||
| 110 | - | Action string `json:"action"` | |||
| 111 | - | Spec *string `json:"spec"` | |||
| 112 | - | PhaseID string `json:"phaseId"` | |||
| 113 | - | PhaseTitle *string `json:"phaseTitle"` | |||
| 114 | - | PhaseGoal *string `json:"phaseGoal"` | |||
| 115 | - | TaskID string `json:"taskId"` | |||
| 116 | - | TaskText *string `json:"taskText"` | |||
| 117 | - | Checked *bool `json:"checked"` | |||
| 118 | - | Phases *[]PhaseInput `json:"phases"` | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | func nowMillis() int64 { return time.Now().UnixMilli() } | |||
| 122 | - | ||||
| 123 | - | func createEmptyState() PlanState { | |||
| 124 | - | return PlanState{ | |||
| 125 | - | Version: 1, | |||
| 126 | - | Phases: []Phase{}, | |||
| 127 | - | NextPhaseNumber: 1, | |||
| 128 | - | NextTaskNumber: 1, | |||
| 129 | - | UpdatedAt: nowMillis(), | |||
| 130 | - | } | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | func cloneState(state PlanState) PlanState { | |||
| 134 | - | cloned := state | |||
| 135 | - | cloned.Phases = make([]Phase, len(state.Phases)) | |||
| 136 | - | for i, phase := range state.Phases { | |||
| 137 | - | cloned.Phases[i] = phase | |||
| 138 | - | cloned.Phases[i].Tasks = append([]PhaseTask(nil), phase.Tasks...) | |||
| 139 | - | } | |||
| 140 | - | return cloned | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | func normalizeOptionalText(value string) string { | |||
| 144 | - | return strings.TrimSpace(value) | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | // sanitizeDisplayText removes terminal escape sequences and non-printing | |||
| 148 | - | // control characters at the rendering boundary. Persisted state is never | |||
| 149 | - | // modified, so the original extension-supplied text remains recoverable. | |||
| 150 | - | func sanitizeDisplayText(text string) string { | |||
| 151 | - | var out strings.Builder | |||
| 152 | - | for index := 0; index < len(text); { | |||
| 153 | - | if text[index] == 0x1b { | |||
| 154 | - | index++ | |||
| 155 | - | if index >= len(text) { | |||
| 156 | - | break | |||
| 157 | - | } | |||
| 158 | - | switch text[index] { | |||
| 159 | - | case '[': // CSI sequence. | |||
| 160 | - | index++ | |||
| 161 | - | for index < len(text) { | |||
| 162 | - | final := text[index] | |||
| 163 | - | index++ | |||
| 164 | - | if final >= 0x40 && final <= 0x7e { | |||
| 165 | - | break | |||
| 166 | - | } | |||
| 167 | - | } | |||
| 168 | - | case ']': // OSC sequence, terminated by BEL or ST. | |||
| 169 | - | index++ | |||
| 170 | - | for index < len(text) { | |||
| 171 | - | if text[index] == 0x07 { | |||
| 172 | - | index++ | |||
| 173 | - | break | |||
| 174 | - | } | |||
| 175 | - | if text[index] == 0x1b && index+1 < len(text) && text[index+1] == '\\' { | |||
| 176 | - | index += 2 | |||
| 177 | - | break | |||
| 178 | - | } | |||
| 179 | - | index++ | |||
| 180 | - | } | |||
| 181 | - | default: | |||
| 182 | - | index++ | |||
| 183 | - | } | |||
| 184 | - | continue | |||
| 185 | - | } | |||
| 186 | - | r, size := utf8.DecodeRuneInString(text[index:]) | |||
| 187 | - | index += size | |||
| 188 | - | if r != '\n' && (unicode.IsControl(r) || r == 0x7f) { | |||
| 189 | - | continue | |||
| 190 | - | } | |||
| 191 | - | out.WriteRune(r) | |||
| 192 | - | } | |||
| 193 | - | return out.String() | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | func singleLine(text string) string { | |||
| 197 | - | return strings.Join(strings.Fields(text), " ") | |||
| 198 | - | } | |||
| 199 | - | ||||
| 200 | - | func displaySingleLine(text string) string { | |||
| 201 | - | return singleLine(sanitizeDisplayText(text)) | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | func truncatePlain(text string, maxLength int) string { | |||
| 205 | - | if maxLength < 1 { | |||
| 206 | - | return "" | |||
| 207 | - | } | |||
| 208 | - | runes := []rune(text) | |||
| 209 | - | if len(runes) <= maxLength { | |||
| 210 | - | return text | |||
| 211 | - | } | |||
| 212 | - | return strings.TrimRight(string(runes[:maxLength-1]), " \t\r\n") + "…" | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | func getPhaseProgress(phase Phase) (done, total int) { | |||
| 216 | - | total = len(phase.Tasks) | |||
| 217 | - | for _, task := range phase.Tasks { | |||
| 218 | - | if task.Checked { | |||
| 219 | - | done++ | |||
| 220 | - | } | |||
| 221 | - | } | |||
| 222 | - | return done, total | |||
| 223 | - | } | |||
| 224 | - | ||||
| 225 | - | func getPlanProgress(state PlanState) (done, total int) { | |||
| 226 | - | for _, phase := range state.Phases { | |||
| 227 | - | phaseDone, phaseTotal := getPhaseProgress(phase) | |||
| 228 | - | done += phaseDone | |||
| 229 | - | total += phaseTotal | |||
| 230 | - | } | |||
| 231 | - | return done, total | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | func isPhaseDone(phase Phase) bool { | |||
| 235 | - | done, total := getPhaseProgress(phase) | |||
| 236 | - | return total > 0 && done == total | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | func hasStoredPlan(state PlanState) bool { | |||
| 240 | - | return state.Spec != "" || len(state.Phases) > 0 | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | func isPlanComplete(state PlanState) bool { | |||
| 244 | - | if len(state.Phases) == 0 { | |||
| 245 | - | return false | |||
| 246 | - | } | |||
| 247 | - | for _, phase := range state.Phases { | |||
| 248 | - | if !isPhaseDone(phase) { | |||
| 249 | - | return false | |||
| 250 | - | } | |||
| 251 | - | } | |||
| 252 | - | return true | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | func isPlanClosed(state PlanState) bool { return state.ClosedAt != nil } | |||
| 256 | - | ||||
| 257 | - | func closePlanIfComplete(state *PlanState) { | |||
| 258 | - | if !isPlanComplete(*state) { | |||
| 259 | - | return | |||
| 260 | - | } | |||
| 261 | - | done, total := getPlanProgress(*state) | |||
| 262 | - | if state.ClosedAt == nil { | |||
| 263 | - | closedAt := nowMillis() | |||
| 264 | - | state.ClosedAt = &closedAt | |||
| 265 | - | } | |||
| 266 | - | state.ClosedSummary = fmt.Sprintf("Completed %d/%d tasks across %d phase(s).", done, total, len(state.Phases)) | |||
| 267 | - | state.CurrentPhaseID = "" | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | func getCurrentPhase(state PlanState) *Phase { | |||
| 271 | - | if state.CurrentPhaseID == "" { | |||
| 272 | - | return nil | |||
| 273 | - | } | |||
| 274 | - | for i := range state.Phases { | |||
| 275 | - | if state.Phases[i].ID == state.CurrentPhaseID { | |||
| 276 | - | return &state.Phases[i] | |||
| 277 | - | } | |||
| 278 | - | } | |||
| 279 | - | return nil | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | - | func getActivePhase(state PlanState) *Phase { | |||
| 283 | - | if current := getCurrentPhase(state); current != nil { | |||
| 284 | - | return current | |||
| 285 | - | } | |||
| 286 | - | for i := range state.Phases { | |||
| 287 | - | if !isPhaseDone(state.Phases[i]) { | |||
| 288 | - | return &state.Phases[i] | |||
| 289 | - | } | |||
| 290 | - | } | |||
| 291 | - | return nil | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | func getSuggestedCurrentPhaseID(state PlanState) string { | |||
| 295 | - | if state.CurrentPhaseID != "" { | |||
| 296 | - | for _, phase := range state.Phases { | |||
| 297 | - | if phase.ID == state.CurrentPhaseID { | |||
| 298 | - | return state.CurrentPhaseID | |||
| 299 | - | } | |||
| 300 | - | } | |||
| 301 | - | } | |||
| 302 | - | for _, phase := range state.Phases { | |||
| 303 | - | if !isPhaseDone(phase) { | |||
| 304 | - | return phase.ID | |||
| 305 | - | } | |||
| 306 | - | } | |||
| 307 | - | if len(state.Phases) > 0 { | |||
| 308 | - | return state.Phases[len(state.Phases)-1].ID | |||
| 309 | - | } | |||
| 310 | - | return "" | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | func extractHighestIDNumber(prefix string, ids []string) int { | |||
| 314 | - | highest := 0 | |||
| 315 | - | pattern := taskIDPattern | |||
| 316 | - | if prefix == "phase" { | |||
| 317 | - | pattern = phaseIDPattern | |||
| 318 | - | } | |||
| 319 | - | for _, id := range ids { | |||
| 320 | - | match := pattern.FindStringSubmatch(id) | |||
| 321 | - | if len(match) != 2 { | |||
| 322 | - | continue | |||
| 323 | - | } | |||
| 324 | - | number, err := strconv.Atoi(match[1]) | |||
| 325 | - | if err == nil && number > highest { | |||
| 326 | - | highest = number | |||
| 327 | - | } | |||
| 328 | - | } | |||
| 329 | - | return highest | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | func ensureState(state PlanState) PlanState { | |||
| 333 | - | normalized := cloneState(state) | |||
| 334 | - | normalized.Version = 1 | |||
| 335 | - | normalized.Spec = normalizeOptionalText(normalized.Spec) | |||
| 336 | - | if normalized.Phases == nil { | |||
| 337 | - | normalized.Phases = []Phase{} | |||
| 338 | - | } | |||
| 339 | - | for i := range normalized.Phases { | |||
| 340 | - | normalized.Phases[i].Goal = optionalNormalized(normalized.Phases[i].Goal) | |||
| 341 | - | if normalized.Phases[i].Tasks == nil { | |||
| 342 | - | normalized.Phases[i].Tasks = []PhaseTask{} | |||
| 343 | - | } | |||
| 344 | - | } | |||
| 345 | - | normalized.ClosedSummary = optionalNormalized(normalized.ClosedSummary) | |||
| 346 | - | if isPlanComplete(normalized) { | |||
| 347 | - | closePlanIfComplete(&normalized) | |||
| 348 | - | } else { | |||
| 349 | - | normalized.ClosedAt = nil | |||
| 350 | - | normalized.ClosedSummary = "" | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | highestPhase := make([]string, 0, len(normalized.Phases)) | |||
| 354 | - | highestTask := make([]string, 0) | |||
| 355 | - | for _, phase := range normalized.Phases { | |||
| 356 | - | highestPhase = append(highestPhase, phase.ID) | |||
| 357 | - | for _, task := range phase.Tasks { | |||
| 358 | - | highestTask = append(highestTask, task.ID) | |||
| 359 | - | } | |||
| 360 | - | } | |||
| 361 | - | if normalized.NextPhaseNumber < 1 { | |||
| 362 | - | normalized.NextPhaseNumber = 1 | |||
| 363 | - | } | |||
| 364 | - | if normalized.NextTaskNumber < 1 { | |||
| 365 | - | normalized.NextTaskNumber = 1 | |||
| 366 | - | } | |||
| 367 | - | normalized.NextPhaseNumber = maxInt(normalized.NextPhaseNumber, extractHighestIDNumber("phase", highestPhase)+1) | |||
| 368 | - | normalized.NextTaskNumber = maxInt(normalized.NextTaskNumber, extractHighestIDNumber("task", highestTask)+1) | |||
| 369 | - | if isPlanClosed(normalized) { | |||
| 370 | - | normalized.CurrentPhaseID = "" | |||
| 371 | - | } else { | |||
| 372 | - | normalized.CurrentPhaseID = getSuggestedCurrentPhaseID(normalized) | |||
| 373 | - | } | |||
| 374 | - | normalized.UpdatedAt = nowMillis() | |||
| 375 | - | return normalized | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | func optionalNormalized(value string) string { | |||
| 379 | - | return normalizeOptionalText(value) | |||
| 380 | - | } | |||
| 381 | - | ||||
| 382 | - | func maxInt(a, b int) int { | |||
| 383 | - | if a > b { | |||
| 384 | - | return a | |||
| 385 | - | } | |||
| 386 | - | return b | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | func nextPhaseID(state *PlanState) string { | |||
| 390 | - | id := fmt.Sprintf("phase-%d", state.NextPhaseNumber) | |||
| 391 | - | state.NextPhaseNumber++ | |||
| 392 | - | return id | |||
| 393 | - | } | |||
| 394 | - | ||||
| 395 | - | func nextTaskID(state *PlanState) string { | |||
| 396 | - | id := fmt.Sprintf("task-%d", state.NextTaskNumber) | |||
| 397 | - | state.NextTaskNumber++ | |||
| 398 | - | return id | |||
| 399 | - | } | |||
| 400 | - | ||||
| 401 | - | func extractBracketedPhaseID(value string) string { | |||
| 402 | - | matches := bracketedIDPattern.FindAllStringSubmatch(value, -1) | |||
| 403 | - | candidate := "" | |||
| 404 | - | for _, match := range matches { | |||
| 405 | - | if len(match) == 2 { | |||
| 406 | - | if normalized := normalizeOptionalText(match[1]); normalized != "" { | |||
| 407 | - | candidate = normalized | |||
| 408 | - | } | |||
| 409 | - | } | |||
| 410 | - | } | |||
| 411 | - | return candidate | |||
| 412 | - | } | |||
| 413 | - | ||||
| 414 | - | func findUniquePhaseByTitle(state PlanState, phaseTitle string) *Phase { | |||
| 415 | - | normalizedTitle := singleLine(phaseTitle) | |||
| 416 | - | var match *Phase | |||
| 417 | - | matches := 0 | |||
| 418 | - | for i := range state.Phases { | |||
| 419 | - | if singleLine(state.Phases[i].Title) == normalizedTitle { | |||
| 420 | - | match = &state.Phases[i] | |||
| 421 | - | matches++ | |||
| 422 | - | } | |||
| 423 | - | } | |||
| 424 | - | if matches == 1 { | |||
| 425 | - | return match | |||
| 426 | - | } | |||
| 427 | - | return nil | |||
| 428 | - | } | |||
| 429 | - | ||||
| 430 | - | // findPhase returns a pointer into state.Phases, aliasing the caller's slice | |||
| 431 | - | // backing array so mutations are applied to the caller's state copy. | |||
| 432 | - | func findPhase(state PlanState, phaseID string) *Phase { | |||
| 433 | - | candidate := normalizeOptionalText(phaseID) | |||
| 434 | - | if candidate == "" { | |||
| 435 | - | return nil | |||
| 436 | - | } | |||
| 437 | - | for i := range state.Phases { | |||
| 438 | - | if state.Phases[i].ID == candidate { | |||
| 439 | - | return &state.Phases[i] | |||
| 440 | - | } | |||
| 441 | - | } | |||
| 442 | - | if bracketedID := extractBracketedPhaseID(candidate); bracketedID != "" { | |||
| 443 | - | for i := range state.Phases { | |||
| 444 | - | if state.Phases[i].ID == bracketedID { | |||
| 445 | - | return &state.Phases[i] | |||
| 446 | - | } | |||
| 447 | - | } | |||
| 448 | - | } | |||
| 449 | - | return findUniquePhaseByTitle(state, candidate) | |||
| 450 | - | } | |||
| 451 | - | ||||
| 452 | - | func formatValidPhaseIDs(state PlanState) string { | |||
| 453 | - | if len(state.Phases) == 0 { | |||
| 454 | - | return "none" | |||
| 455 | - | } | |||
| 456 | - | parts := make([]string, 0, len(state.Phases)) | |||
| 457 | - | for _, phase := range state.Phases { | |||
| 458 | - | parts = append(parts, fmt.Sprintf("%s (%s)", phase.ID, singleLine(phase.Title))) | |||
| 459 | - | } | |||
| 460 | - | return strings.Join(parts, ", ") | |||
| 461 | - | } | |||
| 462 | - | ||||
| 463 | - | func phaseIDError(state PlanState, action, phaseID string) string { | |||
| 464 | - | candidate := normalizeOptionalText(phaseID) | |||
| 465 | - | validIDs := formatValidPhaseIDs(state) | |||
| 466 | - | if candidate == "" { | |||
| 467 | - | return fmt.Sprintf("phaseId is required for %s. Use a valid phase id. Valid phase ids: %s.", action, validIDs) | |||
| 468 | - | } | |||
| 469 | - | return fmt.Sprintf("phaseId %q was not found for %s. Use the raw id from brackets without brackets. Valid phase ids: %s.", singleLine(candidate), action, validIDs) | |||
| 470 | - | } | |||
| 471 | - | ||||
| 472 | - | func formatValidTaskIDs(state PlanState) string { | |||
| 473 | - | parts := make([]string, 0) | |||
| 474 | - | for _, phase := range state.Phases { | |||
| 475 | - | for _, task := range phase.Tasks { | |||
| 476 | - | if !task.Checked { | |||
| 477 | - | parts = append(parts, fmt.Sprintf("%s (%s)", task.ID, displaySingleLine(phase.Title))) | |||
| 478 | - | } | |||
| 479 | - | } | |||
| 480 | - | } | |||
| 481 | - | if len(parts) == 0 { | |||
| 482 | - | return "none" | |||
| 483 | - | } | |||
| 484 | - | return strings.Join(parts, ", ") | |||
| 485 | - | } | |||
| 486 | - | ||||
| 487 | - | func taskIDError(state PlanState, action, taskID string) string { | |||
| 488 | - | candidate := normalizeOptionalText(taskID) | |||
| 489 | - | validIDs := formatValidTaskIDs(state) | |||
| 490 | - | if candidate == "" { | |||
| 491 | - | return fmt.Sprintf("taskId is required for %s. Use a valid task id. Valid incomplete task ids: %s.", action, validIDs) | |||
| 492 | - | } | |||
| 493 | - | return fmt.Sprintf("taskId %q was not found for %s. Use the task id from brackets. Valid incomplete task ids: %s.", displaySingleLine(candidate), action, validIDs) | |||
| 494 | - | } | |||
| 495 | - | ||||
| 496 | - | // findTask returns pointers into state.Phases and their Tasks, aliasing the | |||
| 497 | - | // caller's slice backing arrays so mutations are applied to the caller's state. | |||
| 498 | - | func findTask(state PlanState, taskID, phaseID string) (phase *Phase, task *PhaseTask) { | |||
| 499 | - | if taskID == "" { | |||
| 500 | - | return nil, nil | |||
| 501 | - | } | |||
| 502 | - | if phaseID != "" { | |||
| 503 | - | phase = findPhase(state, phaseID) | |||
| 504 | - | if phase == nil { | |||
| 505 | - | return nil, nil | |||
| 506 | - | } | |||
| 507 | - | for i := range phase.Tasks { | |||
| 508 | - | if phase.Tasks[i].ID == taskID { | |||
| 509 | - | return phase, &phase.Tasks[i] | |||
| 510 | - | } | |||
| 511 | - | } | |||
| 512 | - | return nil, nil | |||
| 513 | - | } | |||
| 514 | - | for i := range state.Phases { | |||
| 515 | - | for j := range state.Phases[i].Tasks { | |||
| 516 | - | if state.Phases[i].Tasks[j].ID == taskID { | |||
| 517 | - | return &state.Phases[i], &state.Phases[i].Tasks[j] | |||
| 518 | - | } | |||
| 519 | - | } | |||
| 520 | - | } | |||
| 521 | - | return nil, nil | |||
| 522 | - | } | |||
| 523 | - | ||||
| 524 | - | func buildPhaseFromInput(state *PlanState, input PhaseInput, phaseIDs, taskIDs map[string]struct{}) (Phase, error) { | |||
| 525 | - | id := "" | |||
| 526 | - | if input.ID != nil { | |||
| 527 | - | id = normalizeOptionalText(*input.ID) | |||
| 528 | - | } | |||
| 529 | - | if id == "" { | |||
| 530 | - | id = nextPhaseID(state) | |||
| 531 | - | } | |||
| 532 | - | if _, exists := phaseIDs[id]; exists { | |||
| 533 | - | return Phase{}, fmt.Errorf("duplicate phase id %q", id) | |||
| 534 | - | } | |||
| 535 | - | phaseIDs[id] = struct{}{} | |||
| 536 | - | phase := Phase{ | |||
| 537 | - | ID: id, | |||
| 538 | - | Title: strings.TrimSpace(input.Title), | |||
| 539 | - | Goal: optionalInputText(input.Goal), | |||
| 540 | - | Tasks: []PhaseTask{}, | |||
| 541 | - | } | |||
| 542 | - | if input.Tasks != nil { | |||
| 543 | - | phase.Tasks = make([]PhaseTask, 0, len(*input.Tasks)) | |||
| 544 | - | for _, inputTask := range *input.Tasks { | |||
| 545 | - | taskID := "" | |||
| 546 | - | if inputTask.ID != nil { | |||
| 547 | - | taskID = normalizeOptionalText(*inputTask.ID) | |||
| 548 | - | } | |||
| 549 | - | if taskID == "" { | |||
| 550 | - | taskID = nextTaskID(state) | |||
| 551 | - | } | |||
| 552 | - | if _, exists := taskIDs[taskID]; exists { | |||
| 553 | - | return Phase{}, fmt.Errorf("duplicate task id %q", taskID) | |||
| 554 | - | } | |||
| 555 | - | taskIDs[taskID] = struct{}{} | |||
| 556 | - | checked := false | |||
| 557 | - | if inputTask.Checked != nil { | |||
| 558 | - | checked = *inputTask.Checked | |||
| 559 | - | } | |||
| 560 | - | phase.Tasks = append(phase.Tasks, PhaseTask{ID: taskID, Text: strings.TrimSpace(inputTask.Text), Checked: checked}) | |||
| 561 | - | } | |||
| 562 | - | } | |||
| 563 | - | return phase, nil | |||
| 564 | - | } | |||
| 565 | - | ||||
| 566 | - | func optionalInputText(value *string) string { | |||
| 567 | - | if value == nil { | |||
| 568 | - | return "" | |||
| 569 | - | } | |||
| 570 | - | return normalizeOptionalText(*value) | |||
| 571 | - | } | |||
| 572 | - | ||||
| 573 | - | // applyAction is the state transition used by the protocol handler. It does | |||
| 574 | - | // not persist the result; stateStore performs that step while holding its | |||
| 575 | - | // mutex, so a failed write never publishes an unpersisted state. | |||
| 576 | - | func applyAction(state PlanState, params toolArgs) (PlanState, string, error) { | |||
| 577 | - | state = cloneState(state) | |||
| 578 | - | nextState := cloneState(state) | |||
| 579 | - | if isPlanClosed(state) && closedPlanBlockedActions[params.Action] { | |||
| 580 | - | reopen := (params.Action == actionSetTaskChecked || params.Action == actionSetPhaseChecked) && params.Checked != nil && !*params.Checked | |||
| 581 | - | if !reopen { | |||
| 582 | - | return state, "Plan is closed", errors.New("this plan is already complete and closed; call clear before starting new work, or call set_spec immediately followed by replace_plan") | |||
| 583 | - | } | |||
| 584 | - | } | |||
| 585 | - | ||||
| 586 | - | switch params.Action { | |||
| 587 | - | case actionGetStatus: | |||
| 588 | - | return state, "Current phased plan status", nil | |||
| 589 | - | case actionSetSpec: | |||
| 590 | - | spec := "" | |||
| 591 | - | if params.Spec != nil { | |||
| 592 | - | spec = normalizeOptionalText(*params.Spec) | |||
| 593 | - | } | |||
| 594 | - | if spec == "" { | |||
| 595 | - | return state, "Spec not updated", errors.New("spec is required for set_spec") | |||
| 596 | - | } | |||
| 597 | - | nextState.Spec = spec | |||
| 598 | - | if isPlanClosed(state) { | |||
| 599 | - | nextState.Phases = []Phase{} | |||
| 600 | - | nextState.CurrentPhaseID = "" | |||
| 601 | - | nextState.NextPhaseNumber = 1 | |||
| 602 | - | nextState.NextTaskNumber = 1 | |||
| 603 | - | } | |||
| 604 | - | nextState.ClosedAt = nil | |||
| 605 | - | nextState.ClosedSummary = "" | |||
| 606 | - | return nextState, "Saved spec", nil | |||
| 607 | - | case actionReplacePlan: | |||
| 608 | - | if params.Phases == nil { | |||
| 609 | - | return state, "Plan not replaced", errors.New("phases is required for replace_plan") | |||
| 610 | - | } | |||
| 611 | - | nextState.Phases = []Phase{} | |||
| 612 | - | nextState.CurrentPhaseID = "" | |||
| 613 | - | nextState.NextPhaseNumber = 1 | |||
| 614 | - | nextState.NextTaskNumber = 1 | |||
| 615 | - | phaseIDs := map[string]struct{}{} | |||
| 616 | - | taskIDs := map[string]struct{}{} | |||
| 617 | - | for _, phaseInput := range *params.Phases { | |||
| 618 | - | phase, err := buildPhaseFromInput(&nextState, phaseInput, phaseIDs, taskIDs) | |||
| 619 | - | if err != nil { | |||
| 620 | - | return state, "Plan not replaced", err | |||
| 621 | - | } | |||
| 622 | - | nextState.Phases = append(nextState.Phases, phase) | |||
| 623 | - | } | |||
| 624 | - | nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState) | |||
| 625 | - | return nextState, fmt.Sprintf("Replaced plan with %d phase(s)", len(nextState.Phases)), nil | |||
| 626 | - | case actionAddPhase: | |||
| 627 | - | phaseTitle := "" | |||
| 628 | - | if params.PhaseTitle != nil { | |||
| 629 | - | phaseTitle = normalizeOptionalText(*params.PhaseTitle) | |||
| 630 | - | } | |||
| 631 | - | if phaseTitle == "" { | |||
| 632 | - | return state, "Phase not added", errors.New("phaseTitle is required for add_phase") | |||
| 633 | - | } | |||
| 634 | - | nextState.Phases = append(nextState.Phases, Phase{ID: nextPhaseID(&nextState), Title: phaseTitle, Tasks: []PhaseTask{}}) | |||
| 635 | - | if params.PhaseGoal != nil { | |||
| 636 | - | nextState.Phases[len(nextState.Phases)-1].Goal = normalizeOptionalText(*params.PhaseGoal) | |||
| 637 | - | } | |||
| 638 | - | if nextState.CurrentPhaseID == "" { | |||
| 639 | - | nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState) | |||
| 640 | - | } | |||
| 641 | - | return nextState, "Added phase " + phaseTitle, nil | |||
| 642 | - | case actionUpdatePhase: | |||
| 643 | - | phase := findPhase(nextState, params.PhaseID) | |||
| 644 | - | if phase == nil { | |||
| 645 | - | return state, "Phase not updated", errors.New(phaseIDError(nextState, actionUpdatePhase, params.PhaseID)) | |||
| 646 | - | } | |||
| 647 | - | if params.PhaseTitle != nil { | |||
| 648 | - | if phaseTitle := normalizeOptionalText(*params.PhaseTitle); phaseTitle != "" { | |||
| 649 | - | phase.Title = phaseTitle | |||
| 650 | - | } | |||
| 651 | - | } | |||
| 652 | - | if params.PhaseGoal != nil { | |||
| 653 | - | phase.Goal = normalizeOptionalText(*params.PhaseGoal) | |||
| 654 | - | } | |||
| 655 | - | return nextState, "Updated phase " + phase.Title, nil | |||
| 656 | - | case actionRemovePhase: | |||
| 657 | - | phase := findPhase(nextState, params.PhaseID) | |||
| 658 | - | if phase == nil { | |||
| 659 | - | return state, "Phase not removed", errors.New(phaseIDError(nextState, actionRemovePhase, params.PhaseID)) | |||
| 660 | - | } | |||
| 661 | - | removedID := phase.ID | |||
| 662 | - | filtered := make([]Phase, 0, len(nextState.Phases)-1) | |||
| 663 | - | for _, entry := range nextState.Phases { | |||
| 664 | - | if entry.ID != removedID { | |||
| 665 | - | filtered = append(filtered, entry) | |||
| 666 | - | } | |||
| 667 | - | } | |||
| 668 | - | nextState.Phases = filtered | |||
| 669 | - | nextState.CurrentPhaseID = getSuggestedCurrentPhaseID(nextState) | |||
| 670 | - | return nextState, "Removed phase " + removedID, nil | |||
| 671 | - | case actionAddTask: | |||
| 672 | - | phase := findPhase(nextState, params.PhaseID) | |||
| 673 | - | taskText := "" | |||
| 674 | - | if params.TaskText != nil { | |||
| 675 | - | taskText = normalizeOptionalText(*params.TaskText) | |||
| 676 | - | } | |||
| 677 | - | if phase == nil { | |||
| 678 | - | return state, "Task not added", errors.New(phaseIDError(nextState, actionAddTask, params.PhaseID)) | |||
| 679 | - | } | |||
| 680 | - | if taskText == "" { | |||
| 681 | - | return state, "Task not added", errors.New("taskText is required for add_task") | |||
| 682 | - | } | |||
| 683 | - | phase.Tasks = append(phase.Tasks, PhaseTask{ID: nextTaskID(&nextState), Text: taskText}) | |||
| 684 | - | return nextState, "Added task to " + phase.Title, nil | |||
| 685 | - | case actionUpdateTask: | |||
| 686 | - | if params.TaskID == "" { | |||
| 687 | - | return state, "Task not updated", errors.New("taskId is required for update_task") | |||
| 688 | - | } | |||
| 689 | - | _, task := findTask(nextState, params.TaskID, params.PhaseID) | |||
| 690 | - | taskText := "" | |||
| 691 | - | if params.TaskText != nil { | |||
| 692 | - | taskText = normalizeOptionalText(*params.TaskText) | |||
| 693 | - | } | |||
| 694 | - | if task == nil { | |||
| 695 | - | return state, "Task not updated", errors.New(taskIDError(nextState, actionUpdateTask, params.TaskID)) | |||
| 696 | - | } | |||
| 697 | - | if taskText == "" { | |||
| 698 | - | return state, "Task not updated", errors.New("taskText is required for update_task") | |||
| 699 | - | } | |||
| 700 | - | task.Text = taskText | |||
| 701 | - | return nextState, "Updated task " + task.ID, nil | |||
| 702 | - | case actionRemoveTask: | |||
| 703 | - | if params.TaskID == "" { | |||
| 704 | - | return state, "Task not removed", errors.New("taskId is required") | |||
| 705 | - | } | |||
| 706 | - | phase, task := findTask(nextState, params.TaskID, params.PhaseID) | |||
| 707 | - | if task == nil { | |||
| 708 | - | return state, "Task not removed", errors.New("taskId was not found") | |||
| 709 | - | } | |||
| 710 | - | filtered := make([]PhaseTask, 0, len(phase.Tasks)-1) | |||
| 711 | - | for _, entry := range phase.Tasks { | |||
| 712 | - | if entry.ID != params.TaskID { | |||
| 713 | - | filtered = append(filtered, entry) | |||
| 714 | - | } | |||
| 715 | - | } | |||
| 716 | - | phase.Tasks = filtered | |||
| 717 | - | return nextState, "Removed task " + params.TaskID, nil | |||
| 718 | - | case actionSetCurrentPhase: | |||
| 719 | - | phase := findPhase(nextState, params.PhaseID) | |||
| 720 | - | if phase == nil { | |||
| 721 | - | return state, "Current phase not updated", errors.New(phaseIDError(nextState, actionSetCurrentPhase, params.PhaseID)) | |||
| 722 | - | } | |||
| 723 | - | nextState.CurrentPhaseID = phase.ID | |||
| 724 | - | return nextState, "Current phase set to " + phase.Title, nil | |||
| 725 | - | case actionSetTaskChecked: | |||
| 726 | - | if params.TaskID == "" { | |||
| 727 | - | return state, "Task not updated", errors.New("taskId is required for set_task_checked") | |||
| 728 | - | } | |||
| 729 | - | phase, task := findTask(nextState, params.TaskID, params.PhaseID) | |||
| 730 | - | if task == nil { | |||
| 731 | - | return state, "Task not updated", errors.New(taskIDError(nextState, actionSetTaskChecked, params.TaskID)) | |||
| 732 | - | } | |||
| 733 | - | if params.Checked == nil { | |||
| 734 | - | return state, "Task not updated", errors.New("checked must be provided for set_task_checked") | |||
| 735 | - | } | |||
| 736 | - | task.Checked = *params.Checked | |||
| 737 | - | if *params.Checked && nextState.CurrentPhaseID == phase.ID && isPhaseDone(*phase) { | |||
| 738 | - | nextState.CurrentPhaseID = "" | |||
| 739 | - | } | |||
| 740 | - | return nextState, fmt.Sprintf("%s task %s", checkedWord(*params.Checked), task.ID), nil | |||
| 741 | - | case actionSetPhaseChecked: | |||
| 742 | - | phase := findPhase(nextState, params.PhaseID) | |||
| 743 | - | if phase == nil { | |||
| 744 | - | return state, "Phase not updated", errors.New(phaseIDError(nextState, actionSetPhaseChecked, params.PhaseID)) | |||
| 745 | - | } | |||
| 746 | - | if params.Checked == nil { | |||
| 747 | - | return state, "Phase not updated", errors.New("checked must be provided for set_phase_checked") | |||
| 748 | - | } | |||
| 749 | - | if len(phase.Tasks) == 0 { | |||
| 750 | - | return state, "Phase not updated", errors.New("set_phase_checked requires the phase to have at least one task") | |||
| 751 | - | } | |||
| 752 | - | for i := range phase.Tasks { | |||
| 753 | - | phase.Tasks[i].Checked = *params.Checked | |||
| 754 | - | } | |||
| 755 | - | if *params.Checked && nextState.CurrentPhaseID == phase.ID { | |||
| 756 | - | nextState.CurrentPhaseID = "" | |||
| 757 | - | } | |||
| 758 | - | if !*params.Checked { | |||
| 759 | - | nextState.CurrentPhaseID = phase.ID | |||
| 760 | - | } | |||
| 761 | - | return nextState, fmt.Sprintf("%s phase %s", checkedWord(*params.Checked), phase.Title), nil | |||
| 762 | - | case actionClear: | |||
| 763 | - | return createEmptyState(), "Cleared stored spec and phased checklist", nil | |||
| 764 | - | default: | |||
| 765 | - | return state, "Unsupported action", errors.New("unknown action") | |||
| 766 | - | } | |||
| 767 | - | } | |||
| 768 | - | ||||
| 769 | - | func checkedWord(checked bool) string { | |||
| 770 | - | if checked { | |||
| 771 | - | return "Checked" | |||
| 772 | - | } | |||
| 773 | - | return "Unchecked" | |||
| 774 | - | } | |||
| 775 | - | ||||
| 776 | - | const ( | |||
| 777 | - | turnContextVisibleIncompleteTasks = 3 | |||
| 778 | - | maxTurnContextChars = 2048 | |||
| 779 | - | ) | |||
| 780 | - | ||||
| 781 | - | func getIncompleteTasks(phase Phase) []PhaseTask { | |||
| 782 | - | incomplete := make([]PhaseTask, 0) | |||
| 783 | - | for _, task := range phase.Tasks { | |||
| 784 | - | if !task.Checked { | |||
| 785 | - | incomplete = append(incomplete, task) | |||
| 786 | - | } | |||
| 787 | - | } | |||
| 788 | - | return incomplete | |||
| 789 | - | } | |||
| 790 | - | ||||
| 791 | - | func formatPhaseTitle(phase Phase) string { return truncatePlain(displaySingleLine(phase.Title), 120) } | |||
| 792 | - | func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) } | |||
| 793 | - | ||||
| 794 | - | func formatTurnContextID(id string) string { return truncatePlain(singleLine(id), 96) } | |||
| 795 | - | ||||
| 796 | - | func appendIncompleteTaskLines(lines *[]string, tasks []PhaseTask, visibleCount int, indent string) { | |||
| 797 | - | visible := tasks | |||
| 798 | - | if len(visible) > visibleCount { | |||
| 799 | - | visible = visible[:visibleCount] | |||
| 800 | - | } | |||
| 801 | - | for _, task := range visible { | |||
| 802 | - | *lines = append(*lines, fmt.Sprintf("%s[ ] %s [%s]", indent, formatTaskText(task), formatTurnContextID(task.ID))) | |||
| 803 | - | } | |||
| 804 | - | if omitted := len(tasks) - len(visible); omitted > 0 { | |||
| 805 | - | *lines = append(*lines, fmt.Sprintf("%s... %d more incomplete task(s) omitted; call tasked_phases get_status for the full checklist.", indent, omitted)) | |||
| 806 | - | } | |||
| 807 | - | } | |||
| 808 | - | ||||
| 809 | - | func buildSummary(state PlanState) string { | |||
| 810 | - | if !hasStoredPlan(state) { | |||
| 811 | - | return "No spec or phased checklist has been stored yet." | |||
| 812 | - | } | |||
| 813 | - | lines := make([]string, 0) | |||
| 814 | - | if state.Spec != "" { | |||
| 815 | - | lines = append(lines, "Spec:", sanitizeDisplayText(state.Spec), "") | |||
| 816 | - | } | |||
| 817 | - | if len(state.Phases) == 0 { | |||
| 818 | - | return strings.Join(append(lines, "Phases: (none)"), "\n") | |||
| 819 | - | } | |||
| 820 | - | done, total := getPlanProgress(state) | |||
| 821 | - | lines = append(lines, fmt.Sprintf("Plan progress: %d/%d tasks checked", done, total)) | |||
| 822 | - | if isPlanClosed(state) { | |||
| 823 | - | closedSummary := state.ClosedSummary | |||
| 824 | - | if closedSummary == "" { | |||
| 825 | - | closedSummary = "all phases complete" | |||
| 826 | - | } | |||
| 827 | - | lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary)) | |||
| 828 | - | } | |||
| 829 | - | lines = append(lines, "Phases:") | |||
| 830 | - | for _, phase := range state.Phases { | |||
| 831 | - | phaseDone, phaseTotal := getPhaseProgress(phase) | |||
| 832 | - | currentMarker := " " | |||
| 833 | - | if phase.ID == state.CurrentPhaseID { | |||
| 834 | - | currentMarker = ">" | |||
| 835 | - | } | |||
| 836 | - | phaseMarker := "[ ]" | |||
| 837 | - | if isPhaseDone(phase) { | |||
| 838 | - | phaseMarker = "[x]" | |||
| 839 | - | } | |||
| 840 | - | goalSuffix := "" | |||
| 841 | - | if phase.Goal != "" { | |||
| 842 | - | goalSuffix = " - " + displaySingleLine(phase.Goal) | |||
| 843 | - | } | |||
| 844 | - | lines = append(lines, fmt.Sprintf("%s %s %s [%s] (%d/%d)%s", currentMarker, phaseMarker, formatPhaseTitle(phase), formatTurnContextID(phase.ID), phaseDone, phaseTotal, goalSuffix)) | |||
| 845 | - | if len(phase.Tasks) == 0 { | |||
| 846 | - | lines = append(lines, " - No tasks yet") | |||
| 847 | - | continue | |||
| 848 | - | } | |||
| 849 | - | for _, task := range phase.Tasks { | |||
| 850 | - | marker := "[ ]" | |||
| 851 | - | if task.Checked { | |||
| 852 | - | marker = "[x]" | |||
| 853 | - | } | |||
| 854 | - | lines = append(lines, fmt.Sprintf(" %s %s [%s]", marker, formatTaskText(task), formatTurnContextID(task.ID))) | |||
| 855 | - | } | |||
| 856 | - | } | |||
| 857 | - | return strings.Join(lines, "\n") | |||
| 858 | - | } | |||
| 859 | - | ||||
| 860 | - | // buildTurnContext returns the small amount of plan state the model needs at | |||
| 861 | - | // the start of a turn. It deliberately excludes the spec, completed phases, | |||
| 862 | - | // and future phases; get_status and /phases remain the full-state views. | |||
| 863 | - | func buildTurnContext(state PlanState) string { | |||
| 864 | - | if !hasStoredPlan(state) { | |||
| 865 | - | return "" | |||
| 866 | - | } | |||
| 867 | - | if isPlanClosed(state) { | |||
| 868 | - | return truncatePlain("Tasked phases focus: the current plan is complete and closed. Use clear before creating a plan for unrelated work.", maxTurnContextChars) | |||
| 869 | - | } | |||
| 870 | - | phase := getActivePhase(state) | |||
| 871 | - | if phase == nil { | |||
| 872 | - | return "" | |||
| 873 | - | } | |||
| 874 | - | lines := []string{ | |||
| 875 | - | "[TASKED PHASES STATE - SOURCE OF TRUTH]", | |||
| 876 | - | "Tasked phases focus:", | |||
| 877 | - | "Call tasked_phases get_status if the exact full checklist is needed.", | |||
| 878 | - | "Update tasked_phases continuously while implementing, not only at the end.", | |||
| 879 | - | "After completing each checklist task, immediately call tasked_phases set_task_checked.", | |||
| 880 | - | "After moving to another phase, immediately call tasked_phases set_current_phase.", | |||
| 881 | - | "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.", | |||
| 882 | - | "Do not rely on prose alone for completion state.", | |||
| 883 | - | fmt.Sprintf("Current phase: %s [%s]", formatPhaseTitle(*phase), formatTurnContextID(phase.ID)), | |||
| 884 | - | } | |||
| 885 | - | if phase.Goal != "" { | |||
| 886 | - | lines = append(lines, "Goal: "+truncatePlain(displaySingleLine(phase.Goal), 160)) | |||
| 887 | - | } | |||
| 888 | - | incompleteTasks := getIncompleteTasks(*phase) | |||
| 889 | - | if len(incompleteTasks) == 0 { | |||
| 890 | - | lines = append(lines, "No incomplete tasks remain in this phase; update the plan or advance the phase as appropriate.") | |||
| 891 | - | return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars) | |||
| 892 | - | } | |||
| 893 | - | lines = append(lines, "Incomplete tasks:") | |||
| 894 | - | appendIncompleteTaskLines(&lines, incompleteTasks, turnContextVisibleIncompleteTasks, " ") | |||
| 895 | - | return truncatePlain(strings.Join(lines, "\n"), maxTurnContextChars) | |||
| 896 | - | } | |||
| 897 | - | ||||
| 898 | - | func shouldReturnFullSummary(action string, err error) bool { | |||
| 899 | - | return err != nil || action == actionGetStatus || action == actionReplacePlan || action == actionClear | |||
| 900 | - | } | |||
| 901 | - | ||||
| 902 | - | func buildToolResultText(action string, state PlanState, headline string, err error) string { | |||
| 903 | - | if err != nil { | |||
| 904 | - | return "Error: " + err.Error() + "\n\n" + buildSummary(state) | |||
| 905 | - | } | |||
| 906 | - | if shouldReturnFullSummary(action, nil) { | |||
| 907 | - | return headline + "\n\n" + buildSummary(state) | |||
| 908 | - | } | |||
| 909 | - | return fmt.Sprintf("%s (%s)", headline, buildChromeSummary(state)) | |||
| 910 | - | } | |||
| 911 | - | ||||
| 912 | - | func buildViewLines(state PlanState) []string { | |||
| 913 | - | lines := []string{"", " Tasked phases ", ""} | |||
| 914 | - | if !hasStoredPlan(state) { | |||
| 915 | - | return append(lines, | |||
| 916 | - | " No spec or phases stored yet.", | |||
| 917 | - | "", | |||
| 918 | - | " Ask the agent to create a spec and phased checklist.", | |||
| 919 | - | "", | |||
| 920 | - | " Press Escape to close", | |||
| 921 | - | ) | |||
| 922 | - | } | |||
| 923 | - | if state.Spec != "" { | |||
| 924 | - | lines = append(lines, " Spec") | |||
| 925 | - | for _, specLine := range strings.Split(sanitizeDisplayText(state.Spec), "\n") { | |||
| 926 | - | lines = append(lines, " "+specLine) | |||
| 927 | - | } | |||
| 928 | - | lines = append(lines, "") | |||
| 929 | - | } | |||
| 930 | - | done, total := getPlanProgress(state) | |||
| 931 | - | lines = append(lines, fmt.Sprintf(" Progress: %d/%d tasks checked", done, total)) | |||
| 932 | - | if isPlanClosed(state) { | |||
| 933 | - | closedSummary := state.ClosedSummary | |||
| 934 | - | if closedSummary == "" { | |||
| 935 | - | closedSummary = "all phases complete" | |||
| 936 | - | } | |||
| 937 | - | lines = append(lines, | |||
| 938 | - | " Closed: "+displaySingleLine(closedSummary), | |||
| 939 | - | " New work should create a fresh plan instead of extending this one.", | |||
| 940 | - | ) | |||
| 941 | - | } | |||
| 942 | - | lines = append(lines, "") | |||
| 943 | - | for _, phase := range state.Phases { | |||
| 944 | - | phaseDone, phaseTotal := getPhaseProgress(phase) | |||
| 945 | - | marker := "[ ]" | |||
| 946 | - | if isPhaseDone(phase) { | |||
| 947 | - | marker = "[x]" | |||
| 948 | - | } | |||
| 949 | - | currentTitle := phase.Title | |||
| 950 | - | if phase.ID == state.CurrentPhaseID { | |||
| 951 | - | currentTitle = "> " + currentTitle | |||
| 952 | - | } | |||
| 953 | - | lines = append(lines, fmt.Sprintf(" %s %s [%s] (%d/%d)", marker, displaySingleLine(currentTitle), formatTurnContextID(phase.ID), phaseDone, phaseTotal)) | |||
| 954 | - | if phase.Goal != "" { | |||
| 955 | - | lines = append(lines, " "+displaySingleLine(phase.Goal)) | |||
| 956 | - | } | |||
| 957 | - | if len(phase.Tasks) == 0 { | |||
| 958 | - | lines = append(lines, " - No tasks yet") | |||
| 959 | - | continue | |||
| 960 | - | } | |||
| 961 | - | for _, task := range phase.Tasks { | |||
| 962 | - | taskMarker := "[ ]" | |||
| 963 | - | if task.Checked { | |||
| 964 | - | taskMarker = "[x]" | |||
| 965 | - | } | |||
| 966 | - | lines = append(lines, fmt.Sprintf(" %s %s [%s]", taskMarker, formatTaskText(task), formatTurnContextID(task.ID))) | |||
| 967 | - | } | |||
| 968 | - | lines = append(lines, "") | |||
| 969 | - | } | |||
| 970 | - | return append(lines, " Press Escape to close") | |||
| 971 | - | } | |||
| 972 | - | ||||
| 973 | - | type stateStore struct { | |||
| 974 | - | mu sync.Mutex | |||
| 975 | - | state PlanState | |||
| 976 | - | path string | |||
| 977 | - | loaded bool | |||
| 978 | - | loadErr error | |||
| 979 | - | // pathErr records a persistence fault independently of the active | |||
| 980 | - | // session state. Activating a session must never clear this fault. | |||
| 981 | - | pathErr error | |||
| 982 | - | } | |||
| 983 | - | ||||
| 984 | - | func newStateStore() *stateStore { | |||
| 985 | - | return &stateStore{state: createEmptyState()} | |||
| 986 | - | } | |||
| 987 | - | ||||
| 988 | - | func projectStatePath(dataDir, cwd string) string { | |||
| 989 | - | key := sha256.Sum256([]byte(cwd)) | |||
| 990 | - | return filepath.Join(dataDir, "projects", hex.EncodeToString(key[:])+".json") | |||
| 991 | - | } | |||
| 992 | - | ||||
| 993 | - | func (s *stateStore) load(dataDir, cwd string) error { | |||
| 994 | - | s.mu.Lock() | |||
| 995 | - | defer s.mu.Unlock() | |||
| 996 | - | if s.loaded { | |||
| 997 | - | return s.loadErr | |||
| 998 | - | } | |||
| 999 | - | s.loaded = true | |||
| 1000 | - | s.state = createEmptyState() | |||
| 1001 | - | if strings.TrimSpace(dataDir) == "" { | |||
| 1002 | - | s.loadErr = errors.New("host did not provide extension data_dir") | |||
| 1003 | - | s.pathErr = s.loadErr | |||
| 1004 | - | return s.loadErr | |||
| 1005 | - | } | |||
| 1006 | - | s.path = projectStatePath(dataDir, cwd) | |||
| 1007 | - | if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { | |||
| 1008 | - | s.loadErr = fmt.Errorf("create state directory: %w", err) | |||
| 1009 | - | s.pathErr = s.loadErr | |||
| 1010 | - | return s.loadErr | |||
| 1011 | - | } | |||
| 1012 | - | // #nosec G302 -- this is a directory; 0700 is the minimum mode that permits traversal. | |||
| 1013 | - | if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil { | |||
| 1014 | - | s.loadErr = fmt.Errorf("restrict state directory: %w", err) | |||
| 1015 | - | s.pathErr = s.loadErr | |||
| 1016 | - | return s.loadErr | |||
| 1017 | - | } | |||
| 1018 | - | data, err := os.ReadFile(s.path) | |||
| 1019 | - | if os.IsNotExist(err) { | |||
| 1020 | - | return nil | |||
| 1021 | - | } | |||
| 1022 | - | if err != nil { | |||
| 1023 | - | s.loadErr = fmt.Errorf("read state: %w", err) | |||
| 1024 | - | s.pathErr = s.loadErr | |||
| 1025 | - | return s.loadErr | |||
| 1026 | - | } | |||
| 1027 | - | if err := os.Chmod(s.path, 0o600); err != nil { | |||
| 1028 | - | s.loadErr = fmt.Errorf("restrict state file: %w", err) | |||
| 1029 | - | s.pathErr = s.loadErr | |||
| 1030 | - | return s.loadErr | |||
| 1031 | - | } | |||
| 1032 | - | if len(data) == 0 { | |||
| 1033 | - | return nil | |||
| 1034 | - | } | |||
| 1035 | - | var state PlanState | |||
| 1036 | - | if err := json.Unmarshal(data, &state); err != nil { | |||
| 1037 | - | s.loadErr = fmt.Errorf("parse state: %w", err) | |||
| 1038 | - | s.pathErr = s.loadErr | |||
| 1039 | - | return s.loadErr | |||
| 1040 | - | } | |||
| 1041 | - | s.state = ensureState(state) | |||
| 1042 | - | return nil | |||
| 1043 | - | } | |||
| 1044 | - | ||||
| 1045 | - | // activateSession replaces the project fallback with the opaque snapshot | |||
| 1046 | - | // supplied by zut for the active session branch. An empty snapshot means the | |||
| 1047 | - | // branch has no extension state yet and therefore starts clean. | |||
| 1048 | - | func (s *stateStore) activateSession(raw json.RawMessage) error { | |||
| 1049 | - | s.mu.Lock() | |||
| 1050 | - | defer s.mu.Unlock() | |||
| 1051 | - | if !s.loaded { | |||
| 1052 | - | return errors.New("state has not been loaded from hello") | |||
| 1053 | - | } | |||
| 1054 | - | if s.pathErr != nil { | |||
| 1055 | - | return s.pathErr | |||
| 1056 | - | } | |||
| 1057 | - | if len(raw) == 0 || strings.TrimSpace(string(raw)) == "null" { | |||
| 1058 | - | s.state = createEmptyState() | |||
| 1059 | - | s.loadErr = nil | |||
| 1060 | - | return nil | |||
| 1061 | - | } | |||
| 1062 | - | var state PlanState | |||
| 1063 | - | if err := json.Unmarshal(raw, &state); err != nil { | |||
| 1064 | - | return fmt.Errorf("parse session state: %w", err) | |||
| 1065 | - | } | |||
| 1066 | - | s.state = ensureState(state) | |||
| 1067 | - | s.loadErr = nil | |||
| 1068 | - | return nil | |||
| 1069 | - | } | |||
| 1070 | - | ||||
| 1071 | - | func (s *stateStore) snapshot() (PlanState, error) { | |||
| 1072 | - | s.mu.Lock() | |||
| 1073 | - | defer s.mu.Unlock() | |||
| 1074 | - | if !s.loaded { | |||
| 1075 | - | return PlanState{}, errors.New("state has not been loaded from hello") | |||
| 1076 | - | } | |||
| 1077 | - | if s.loadErr != nil { | |||
| 1078 | - | return cloneState(s.state), s.loadErr | |||
| 1079 | - | } | |||
| 1080 | - | return cloneState(s.state), nil | |||
| 1081 | - | } | |||
| 1082 | - | ||||
| 1083 | - | func (s *stateStore) transact(fn func(PlanState) (PlanState, string, error)) (PlanState, string, error) { | |||
| 1084 | - | s.mu.Lock() | |||
| 1085 | - | defer s.mu.Unlock() | |||
| 1086 | - | if !s.loaded { | |||
| 1087 | - | return PlanState{}, "", errors.New("state has not been loaded from hello") | |||
| 1088 | - | } | |||
| 1089 | - | if s.pathErr != nil { | |||
| 1090 | - | return cloneState(s.state), "", s.pathErr | |||
| 1091 | - | } | |||
| 1092 | - | if s.loadErr != nil { | |||
| 1093 | - | return cloneState(s.state), "", s.loadErr | |||
| 1094 | - | } | |||
| 1095 | - | current := cloneState(s.state) | |||
| 1096 | - | next, headline, err := fn(current) | |||
| 1097 | - | if err != nil { | |||
| 1098 | - | return current, headline, err | |||
| 1099 | - | } | |||
| 1100 | - | next = ensureState(next) | |||
| 1101 | - | if err := writeJSONAtomic(s.path, next); err != nil { | |||
| 1102 | - | return current, headline, fmt.Errorf("persist state: %w", err) | |||
| 1103 | - | } | |||
| 1104 | - | s.state = next | |||
| 1105 | - | return cloneState(next), headline, nil | |||
| 1106 | - | } | |||
| 1107 | - | ||||
| 1108 | - | func writeJSONAtomic(path string, value any) error { | |||
| 1109 | - | if strings.TrimSpace(path) == "" { | |||
| 1110 | - | return errors.New("state path is empty") | |||
| 1111 | - | } | |||
| 1112 | - | data, err := json.MarshalIndent(value, "", " ") | |||
| 1113 | - | if err != nil { | |||
| 1114 | - | return err | |||
| 1115 | - | } | |||
| 1116 | - | data = append(data, '\n') | |||
| 1117 | - | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 1118 | - | return err | |||
| 1119 | - | } | |||
| 1120 | - | tmp, err := os.CreateTemp(filepath.Dir(path), ".tasked-phases-*.tmp") | |||
| 1121 | - | if err != nil { | |||
| 1122 | - | return err | |||
| 1123 | - | } | |||
| 1124 | - | tmpName := tmp.Name() | |||
| 1125 | - | defer os.Remove(tmpName) | |||
| 1126 | - | if err := tmp.Chmod(0o600); err != nil { | |||
| 1127 | - | _ = tmp.Close() | |||
| 1128 | - | return err | |||
| 1129 | - | } | |||
| 1130 | - | if _, err := tmp.Write(data); err != nil { | |||
| 1131 | - | _ = tmp.Close() | |||
| 1132 | - | return err | |||
| 1133 | - | } | |||
| 1134 | - | if err := tmp.Sync(); err != nil { | |||
| 1135 | - | _ = tmp.Close() | |||
| 1136 | - | return err | |||
| 1137 | - | } | |||
| 1138 | - | if err := tmp.Close(); err != nil { | |||
| 1139 | - | return err | |||
| 1140 | - | } | |||
| 1141 | - | if err := os.Rename(tmpName, path); err != nil { | |||
| 1142 | - | return err | |||
| 1143 | - | } | |||
| 1144 | - | return nil | |||
| 1145 | - | } | |||
D examples/extensions/tasked-phases/state_test.go +0 -476
| @@ -1,476 +0,0 @@ | |||||
| 1 | - | package main | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "encoding/json" | |||
| 5 | - | "os" | |||
| 6 | - | "path/filepath" | |||
| 7 | - | "runtime" | |||
| 8 | - | "strings" | |||
| 9 | - | "sync" | |||
| 10 | - | "testing" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | func stringPtr(value string) *string { return &value } | |||
| 14 | - | func boolPtr(value bool) *bool { return &value } | |||
| 15 | - | ||||
| 16 | - | func transition(t *testing.T, state PlanState, params toolArgs) PlanState { | |||
| 17 | - | t.Helper() | |||
| 18 | - | next, _, err := applyAction(ensureState(state), params) | |||
| 19 | - | if err != nil { | |||
| 20 | - | t.Fatalf("apply %s: %v", params.Action, err) | |||
| 21 | - | } | |||
| 22 | - | return ensureState(next) | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | func sampleState() PlanState { | |||
| 26 | - | return ensureState(PlanState{ | |||
| 27 | - | Version: 1, | |||
| 28 | - | Spec: "Ship compact tasked phases output without losing source-of-truth state.", | |||
| 29 | - | Phases: []Phase{ | |||
| 30 | - | { | |||
| 31 | - | ID: "phase-1", | |||
| 32 | - | Title: "Finished discovery", | |||
| 33 | - | Goal: "Understand the old behavior", | |||
| 34 | - | Tasks: []PhaseTask{ | |||
| 35 | - | {ID: "task-1", Text: "Completed discovery task", Checked: true}, | |||
| 36 | - | {ID: "task-2", Text: "Completed contract task", Checked: true}, | |||
| 37 | - | }, | |||
| 38 | - | }, | |||
| 39 | - | { | |||
| 40 | - | ID: "phase-2", | |||
| 41 | - | Title: "Implement compact output", | |||
| 42 | - | Goal: "Reduce repeated context", | |||
| 43 | - | Tasks: []PhaseTask{ | |||
| 44 | - | {ID: "task-3", Text: "Completed implementation task", Checked: true}, | |||
| 45 | - | {ID: "task-4", Text: "Remaining implementation task", Checked: false}, | |||
| 46 | - | }, | |||
| 47 | - | }, | |||
| 48 | - | }, | |||
| 49 | - | CurrentPhaseID: "phase-2", | |||
| 50 | - | NextPhaseNumber: 3, | |||
| 51 | - | NextTaskNumber: 5, | |||
| 52 | - | }) | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | func TestTransitionsGenerateIDsAndSelectCurrentPhase(t *testing.T) { | |||
| 56 | - | state := transition(t, createEmptyState(), toolArgs{ | |||
| 57 | - | Action: actionReplacePlan, | |||
| 58 | - | Phases: &[]PhaseInput{ | |||
| 59 | - | {Title: "First", Tasks: &[]TaskInput{{Text: "one"}}}, | |||
| 60 | - | {Title: "Second", Tasks: &[]TaskInput{{Text: "two"}}}, | |||
| 61 | - | }, | |||
| 62 | - | }) | |||
| 63 | - | if got := state.Phases[0].ID; got != "phase-1" { | |||
| 64 | - | t.Fatalf("first generated phase id = %q", got) | |||
| 65 | - | } | |||
| 66 | - | if got := state.Phases[1].ID; got != "phase-2" { | |||
| 67 | - | t.Fatalf("second generated phase id = %q", got) | |||
| 68 | - | } | |||
| 69 | - | if got := state.Phases[0].Tasks[0].ID; got != "task-1" { | |||
| 70 | - | t.Fatalf("first generated task id = %q", got) | |||
| 71 | - | } | |||
| 72 | - | if state.CurrentPhaseID != "phase-1" || state.NextPhaseNumber != 3 || state.NextTaskNumber != 3 { | |||
| 73 | - | t.Fatalf("generated counters/current phase = %q, %d, %d", state.CurrentPhaseID, state.NextPhaseNumber, state.NextTaskNumber) | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)}) | |||
| 77 | - | if state.CurrentPhaseID != "phase-2" { | |||
| 78 | - | t.Fatalf("current phase did not advance to the first incomplete phase: %q", state.CurrentPhaseID) | |||
| 79 | - | } | |||
| 80 | - | state = transition(t, state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("Third")}) | |||
| 81 | - | if got := state.Phases[2].ID; got != "phase-3" { | |||
| 82 | - | t.Fatalf("added phase id = %q", got) | |||
| 83 | - | } | |||
| 84 | - | state = transition(t, state, toolArgs{Action: actionAddTask, PhaseID: "phase-3", TaskText: stringPtr("three")}) | |||
| 85 | - | if got := state.Phases[2].Tasks[0].ID; got != "task-3" { | |||
| 86 | - | t.Fatalf("added task id = %q", got) | |||
| 87 | - | } | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | func TestReplacePlanRejectsDuplicateIDs(t *testing.T) { | |||
| 91 | - | state := createEmptyState() | |||
| 92 | - | _, _, err := applyAction(state, toolArgs{ | |||
| 93 | - | Action: actionReplacePlan, | |||
| 94 | - | Phases: &[]PhaseInput{ | |||
| 95 | - | {ID: stringPtr("phase-1"), Title: "First"}, | |||
| 96 | - | {ID: stringPtr("phase-1"), Title: "Duplicate"}, | |||
| 97 | - | }, | |||
| 98 | - | }) | |||
| 99 | - | if err == nil || !strings.Contains(err.Error(), "duplicate phase id") { | |||
| 100 | - | t.Fatalf("duplicate phase id error = %v", err) | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | _, _, err = applyAction(state, toolArgs{ | |||
| 104 | - | Action: actionReplacePlan, | |||
| 105 | - | Phases: &[]PhaseInput{ | |||
| 106 | - | {Title: "First", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "one"}}}, | |||
| 107 | - | {Title: "Second", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "duplicate"}}}, | |||
| 108 | - | }, | |||
| 109 | - | }) | |||
| 110 | - | if err == nil || !strings.Contains(err.Error(), "duplicate task id") { | |||
| 111 | - | t.Fatalf("duplicate task id error = %v", err) | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | func TestClosedPlanRulesAndReopen(t *testing.T) { | |||
| 116 | - | state := transition(t, createEmptyState(), toolArgs{ | |||
| 117 | - | Action: actionReplacePlan, | |||
| 118 | - | Phases: &[]PhaseInput{{Title: "Done", Tasks: &[]TaskInput{{Text: "finished", Checked: boolPtr(true)}}}}, | |||
| 119 | - | }) | |||
| 120 | - | if !isPlanClosed(state) || state.CurrentPhaseID != "" { | |||
| 121 | - | t.Fatalf("complete plan was not closed: %+v", state) | |||
| 122 | - | } | |||
| 123 | - | if !strings.Contains(buildSummary(state), "Plan closed: Completed 1/1 tasks across 1 phase(s).") { | |||
| 124 | - | t.Fatalf("closed summary missing: %s", buildSummary(state)) | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | if _, _, err := applyAction(state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("new")}); err == nil || !strings.Contains(err.Error(), "already complete and closed") { | |||
| 128 | - | t.Fatalf("add to closed plan error = %v", err) | |||
| 129 | - | } | |||
| 130 | - | if _, _, err := applyAction(state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{}}); err == nil { | |||
| 131 | - | t.Fatal("replace_plan unexpectedly bypassed closed-plan guard") | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(false)}) | |||
| 135 | - | if isPlanClosed(state) || state.CurrentPhaseID != "phase-1" || state.Phases[0].Tasks[0].Checked { | |||
| 136 | - | t.Fatalf("unchecked reopen produced %+v", state) | |||
| 137 | - | } | |||
| 138 | - | ||||
| 139 | - | state = transition(t, state, toolArgs{Action: actionSetSpec, Spec: stringPtr("new work")}) | |||
| 140 | - | if len(state.Phases) != 1 { | |||
| 141 | - | t.Fatalf("set_spec on reopened plan unexpectedly cleared phases") | |||
| 142 | - | } | |||
| 143 | - | closed := transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)}) | |||
| 144 | - | if !isPlanClosed(closed) { | |||
| 145 | - | t.Fatal("plan did not close again") | |||
| 146 | - | } | |||
| 147 | - | state = transition(t, closed, toolArgs{Action: actionSetSpec, Spec: stringPtr("fresh work")}) | |||
| 148 | - | if len(state.Phases) != 0 || state.Spec != "fresh work" || state.NextPhaseNumber != 1 || state.NextTaskNumber != 1 { | |||
| 149 | - | t.Fatalf("set_spec did not restart a closed plan: %+v", state) | |||
| 150 | - | } | |||
| 151 | - | state = transition(t, state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{{Title: "Fresh"}}}) | |||
| 152 | - | if len(state.Phases) != 1 || isPlanClosed(state) { | |||
| 153 | - | t.Fatalf("replace after set_spec failed: %+v", state) | |||
| 154 | - | } | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | func TestPhaseLookupAcceptsRawBracketedSummaryAndUniqueTitle(t *testing.T) { | |||
| 158 | - | state := sampleState() | |||
| 159 | - | for _, input := range []string{ | |||
| 160 | - | "phase-2", | |||
| 161 | - | "[phase-2]", | |||
| 162 | - | "Implement compact output [phase-2]", | |||
| 163 | - | "Implement compact output [phase-2] (1 remaining) - Reduce repeated context", | |||
| 164 | - | "Implement compact output", | |||
| 165 | - | } { | |||
| 166 | - | if phase := findPhase(state, input); phase == nil || phase.ID != "phase-2" { | |||
| 167 | - | t.Fatalf("findPhase(%q) = %#v", input, phase) | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | if findPhase(state, "missing") != nil || findPhase(state, "") != nil { | |||
| 171 | - | t.Fatal("missing phase lookup unexpectedly matched") | |||
| 172 | - | } | |||
| 173 | - | duplicate := cloneState(state) | |||
| 174 | - | duplicate.Phases[0].Title = "Duplicate" | |||
| 175 | - | duplicate.Phases[1].Title = "Duplicate" | |||
| 176 | - | if findPhase(duplicate, "Duplicate") != nil { | |||
| 177 | - | t.Fatal("ambiguous title unexpectedly matched") | |||
| 178 | - | } | |||
| 179 | - | if phase := findPhase(duplicate, "phase-2"); phase == nil || phase.ID != "phase-2" { | |||
| 180 | - | t.Fatal("raw id did not win over duplicate title") | |||
| 181 | - | } | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) { | |||
| 185 | - | state := sampleState() | |||
| 186 | - | compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil) | |||
| 187 | - | if want := "Checked task task-3 (p 1/2 | t 3/4)"; compact != want { | |||
| 188 | - | t.Fatalf("mutation result = %q, want %q", compact, want) | |||
| 189 | - | } | |||
| 190 | - | full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil) | |||
| 191 | - | if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") { | |||
| 192 | - | t.Fatalf("full summary omitted checklist: %s", full) | |||
| 193 | - | } | |||
| 194 | - | if !strings.Contains(buildSummary(state), "[phase-2] (1/2) - Reduce repeated context") { | |||
| 195 | - | t.Fatal("phase goal/progress missing from full summary") | |||
| 196 | - | } | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) { | |||
| 200 | - | state := sampleState() | |||
| 201 | - | state.Phases[1].Tasks = append(state.Phases[1].Tasks, | |||
| 202 | - | PhaseTask{ID: "task-5", Text: "Next focused task"}, | |||
| 203 | - | PhaseTask{ID: "task-6", Text: "Another focused task"}, | |||
| 204 | - | PhaseTask{ID: "task-7", Text: "Deferred focused task"}, | |||
| 205 | - | ) | |||
| 206 | - | state.Phases = append(state.Phases, Phase{ | |||
| 207 | - | ID: "phase-3", | |||
| 208 | - | Title: "Future phase", | |||
| 209 | - | Tasks: []PhaseTask{{ID: "task-8", Text: "Future task"}}, | |||
| 210 | - | }) | |||
| 211 | - | context := buildTurnContext(state) | |||
| 212 | - | for _, want := range []string{ | |||
| 213 | - | "Tasked phases focus:", | |||
| 214 | - | "Current phase: Implement compact output [phase-2]", | |||
| 215 | - | "Goal: Reduce repeated context", | |||
| 216 | - | "[ ] Remaining implementation task [task-4]", | |||
| 217 | - | "[ ] Next focused task [task-5]", | |||
| 218 | - | "[ ] Another focused task [task-6]", | |||
| 219 | - | } { | |||
| 220 | - | if !strings.Contains(context, want) { | |||
| 221 | - | t.Errorf("turn context missing %q: %s", want, context) | |||
| 222 | - | } | |||
| 223 | - | } | |||
| 224 | - | for _, unwanted := range []string{ | |||
| 225 | - | "Ship compact tasked phases output", | |||
| 226 | - | "Finished discovery", | |||
| 227 | - | "Completed discovery task", | |||
| 228 | - | "Future phase", | |||
| 229 | - | "Future task", | |||
| 230 | - | "Deferred focused task [task-7]", | |||
| 231 | - | } { | |||
| 232 | - | if strings.Contains(context, unwanted) { | |||
| 233 | - | t.Errorf("turn context included %q: %s", unwanted, context) | |||
| 234 | - | } | |||
| 235 | - | } | |||
| 236 | - | } | |||
| 237 | - | ||||
| 238 | - | func TestTurnContextRemindsAgentToTrackProgressContinuously(t *testing.T) { | |||
| 239 | - | context := buildTurnContext(sampleState()) | |||
| 240 | - | for _, want := range []string{ | |||
| 241 | - | "[TASKED PHASES STATE - SOURCE OF TRUTH]", | |||
| 242 | - | "Update tasked_phases continuously while implementing, not only at the end.", | |||
| 243 | - | "After completing each checklist task, immediately call tasked_phases set_task_checked.", | |||
| 244 | - | "After moving to another phase, immediately call tasked_phases set_current_phase.", | |||
| 245 | - | "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.", | |||
| 246 | - | "Do not rely on prose alone for completion state.", | |||
| 247 | - | } { | |||
| 248 | - | if !strings.Contains(context, want) { | |||
| 249 | - | t.Errorf("turn context missing continuous-tracking guidance %q: %s", want, context) | |||
| 250 | - | } | |||
| 251 | - | } | |||
| 252 | - | } | |||
| 253 | - | ||||
| 254 | - | func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) { | |||
| 255 | - | state := sampleState() | |||
| 256 | - | state.Phases[1].ID = strings.Repeat("phase-id-", 1000) | |||
| 257 | - | state.CurrentPhaseID = state.Phases[1].ID | |||
| 258 | - | state.Phases[1].Tasks[0].ID = strings.Repeat("task-id-", 1000) | |||
| 259 | - | ||||
| 260 | - | context := buildTurnContext(state) | |||
| 261 | - | if got := len([]rune(context)); got > maxTurnContextChars { | |||
| 262 | - | t.Fatalf("turn context length = %d; want at most %d", got, maxTurnContextChars) | |||
| 263 | - | } | |||
| 264 | - | if !strings.Contains(context, "Tasked phases focus:") || !strings.Contains(context, "Current phase:") || !strings.Contains(context, "Goal: Reduce repeated context") { | |||
| 265 | - | t.Fatalf("bounded turn context lost its useful focus: %q", context) | |||
| 266 | - | } | |||
| 267 | - | if !strings.Contains(context, "Remaining implementation task") { | |||
| 268 | - | t.Fatalf("per-field id bounding did not leave room for task lines: %q", context) | |||
| 269 | - | } | |||
| 270 | - | for _, line := range strings.Split(context, "\n") { | |||
| 271 | - | if got := len([]rune(line)); got > 400 { | |||
| 272 | - | t.Fatalf("unbounded field produced a %d-rune line: %q", got, line) | |||
| 273 | - | } | |||
| 274 | - | } | |||
| 275 | - | } | |||
| 276 | - | ||||
| 277 | - | func TestDisplayRenderingSanitizesUntrustedTextWithoutMutatingState(t *testing.T) { | |||
| 278 | - | state := sampleState() | |||
| 279 | - | state.Spec = "spec \x1b[31mred\x1b[0m\x00" | |||
| 280 | - | state.Phases[1].Title = "title \x1b]0;evil\a" | |||
| 281 | - | state.Phases[1].Goal = "goal\r\t\x1b[2J" | |||
| 282 | - | state.Phases[1].Tasks[0].Text = "task \x1b[?25l\x7f" | |||
| 283 | - | original := state | |||
| 284 | - | ||||
| 285 | - | for _, rendered := range []string{ | |||
| 286 | - | buildSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"), | |||
| 287 | - | } { | |||
| 288 | - | if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') { | |||
| 289 | - | t.Fatalf("rendered text contains terminal/control injection: %q", rendered) | |||
| 290 | - | } | |||
| 291 | - | } | |||
| 292 | - | if state.Spec != original.Spec || state.Phases[1].Title != original.Phases[1].Title || state.Phases[1].Tasks[0].Text != original.Phases[1].Tasks[0].Text { | |||
| 293 | - | t.Fatal("render sanitization mutated persisted plan state") | |||
| 294 | - | } | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | func TestTaskErrorsDistinguishMissingAndUnknownIDs(t *testing.T) { | |||
| 298 | - | state := sampleState() | |||
| 299 | - | for _, action := range []string{actionUpdateTask, actionSetTaskChecked} { | |||
| 300 | - | if _, _, err := applyAction(state, toolArgs{Action: action}); err == nil || !strings.Contains(err.Error(), "taskId is required") { | |||
| 301 | - | t.Fatalf("%s missing task id error = %v", action, err) | |||
| 302 | - | } | |||
| 303 | - | if _, _, err := applyAction(state, toolArgs{Action: action, TaskID: "missing"}); err == nil || !strings.Contains(err.Error(), "Valid incomplete task ids: task-4") { | |||
| 304 | - | t.Fatalf("%s unknown task id error = %v", action, err) | |||
| 305 | - | } | |||
| 306 | - | } | |||
| 307 | - | } | |||
| 308 | - | ||||
| 309 | - | func TestPanelRendersSpecGoalsTasksAndEmptyState(t *testing.T) { | |||
| 310 | - | empty := strings.Join(buildViewLines(createEmptyState()), "\n") | |||
| 311 | - | if !strings.Contains(empty, "No spec or phases stored yet.") || !strings.Contains(empty, "Press Escape to close") { | |||
| 312 | - | t.Fatalf("empty panel = %s", empty) | |||
| 313 | - | } | |||
| 314 | - | panel := strings.Join(buildViewLines(sampleState()), "\n") | |||
| 315 | - | for _, want := range []string{ | |||
| 316 | - | "Tasked phases", | |||
| 317 | - | "Spec", | |||
| 318 | - | "Progress: 3/4 tasks checked", | |||
| 319 | - | "[x] Completed discovery task [task-1]", | |||
| 320 | - | "[ ] Remaining implementation task [task-4]", | |||
| 321 | - | "Understand the old behavior", | |||
| 322 | - | "Press Escape to close", | |||
| 323 | - | } { | |||
| 324 | - | if !strings.Contains(panel, want) { | |||
| 325 | - | t.Errorf("panel missing %q: %s", want, panel) | |||
| 326 | - | } | |||
| 327 | - | } | |||
| 328 | - | } | |||
| 329 | - | ||||
| 330 | - | func TestStatePersistenceIsProjectScopedAtomicAndRestrictive(t *testing.T) { | |||
| 331 | - | dataDir := t.TempDir() | |||
| 332 | - | first := newStateStore() | |||
| 333 | - | if err := first.load(dataDir, "/projects/one"); err != nil { | |||
| 334 | - | t.Fatal(err) | |||
| 335 | - | } | |||
| 336 | - | state, _, err := first.transact(func(current PlanState) (PlanState, string, error) { | |||
| 337 | - | return applyAction(current, toolArgs{ | |||
| 338 | - | Action: actionReplacePlan, | |||
| 339 | - | Phases: &[]PhaseInput{{Title: "Persisted", Tasks: &[]TaskInput{{Text: "remember me"}}}}, | |||
| 340 | - | }) | |||
| 341 | - | }) | |||
| 342 | - | if err != nil { | |||
| 343 | - | t.Fatal(err) | |||
| 344 | - | } | |||
| 345 | - | if state.Phases[0].ID != "phase-1" { | |||
| 346 | - | t.Fatalf("persisted state = %+v", state) | |||
| 347 | - | } | |||
| 348 | - | if runtime.GOOS != "windows" { | |||
| 349 | - | info, err := os.Stat(first.path) | |||
| 350 | - | if err != nil { | |||
| 351 | - | t.Fatal(err) | |||
| 352 | - | } | |||
| 353 | - | if got := info.Mode().Perm(); got != 0o600 { | |||
| 354 | - | t.Fatalf("state file mode = %o, want 600", got) | |||
| 355 | - | } | |||
| 356 | - | if got := (mustStat(t, filepath.Dir(first.path))).Mode().Perm(); got != 0o700 { | |||
| 357 | - | t.Fatalf("state directory mode = %o, want 700", got) | |||
| 358 | - | } | |||
| 359 | - | } | |||
| 360 | - | ||||
| 361 | - | second := newStateStore() | |||
| 362 | - | if err := second.load(dataDir, "/projects/one"); err != nil { | |||
| 363 | - | t.Fatal(err) | |||
| 364 | - | } | |||
| 365 | - | loaded, err := second.snapshot() | |||
| 366 | - | if err != nil { | |||
| 367 | - | t.Fatal(err) | |||
| 368 | - | } | |||
| 369 | - | if loaded.Phases[0].Tasks[0].Text != "remember me" { | |||
| 370 | - | t.Fatalf("loaded state = %+v", loaded) | |||
| 371 | - | } | |||
| 372 | - | otherProject := newStateStore() | |||
| 373 | - | if err := otherProject.load(dataDir, "/projects/two"); err != nil { | |||
| 374 | - | t.Fatal(err) | |||
| 375 | - | } | |||
| 376 | - | other, err := otherProject.snapshot() | |||
| 377 | - | if err != nil { | |||
| 378 | - | t.Fatal(err) | |||
| 379 | - | } | |||
| 380 | - | if hasStoredPlan(other) { | |||
| 381 | - | t.Fatal("state leaked between project CWDs") | |||
| 382 | - | } | |||
| 383 | - | files, err := os.ReadDir(filepath.Dir(first.path)) | |||
| 384 | - | if err != nil { | |||
| 385 | - | t.Fatal(err) | |||
| 386 | - | } | |||
| 387 | - | for _, file := range files { | |||
| 388 | - | if strings.HasSuffix(file.Name(), ".tmp") { | |||
| 389 | - | t.Fatalf("temporary file left behind: %s", file.Name()) | |||
| 390 | - | } | |||
| 391 | - | } | |||
| 392 | - | ||||
| 393 | - | var decoded PlanState | |||
| 394 | - | data, err := os.ReadFile(first.path) | |||
| 395 | - | if err != nil { | |||
| 396 | - | t.Fatal(err) | |||
| 397 | - | } | |||
| 398 | - | if err := json.Unmarshal(data, &decoded); err != nil { | |||
| 399 | - | t.Fatal(err) | |||
| 400 | - | } | |||
| 401 | - | if decoded.Phases[0].Tasks[0].ID != "task-1" { | |||
| 402 | - | t.Fatalf("on-disk state = %+v", decoded) | |||
| 403 | - | } | |||
| 404 | - | } | |||
| 405 | - | ||||
| 406 | - | func TestStateStoreDoesNotOverwriteCorruptStateAfterSessionActivation(t *testing.T) { | |||
| 407 | - | dataDir := t.TempDir() | |||
| 408 | - | cwd := "/projects/corrupt" | |||
| 409 | - | path := projectStatePath(dataDir, cwd) | |||
| 410 | - | original := []byte("not valid plan json") | |||
| 411 | - | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 412 | - | t.Fatal(err) | |||
| 413 | - | } | |||
| 414 | - | if err := os.WriteFile(path, original, 0o600); err != nil { | |||
| 415 | - | t.Fatal(err) | |||
| 416 | - | } | |||
| 417 | - | ||||
| 418 | - | store := newStateStore() | |||
| 419 | - | if err := store.load(dataDir, cwd); err == nil { | |||
| 420 | - | t.Fatal("corrupt state unexpectedly loaded") | |||
| 421 | - | } | |||
| 422 | - | if err := store.activateSession(nil); err == nil { | |||
| 423 | - | t.Fatal("session activation cleared the corrupt-state fault") | |||
| 424 | - | } | |||
| 425 | - | if _, _, err := store.transact(func(current PlanState) (PlanState, string, error) { | |||
| 426 | - | return applyAction(current, toolArgs{Action: actionClear}) | |||
| 427 | - | }); err == nil { | |||
| 428 | - | t.Fatal("transaction unexpectedly wrote through a corrupt-state fault") | |||
| 429 | - | } | |||
| 430 | - | got, err := os.ReadFile(path) | |||
| 431 | - | if err != nil { | |||
| 432 | - | t.Fatal(err) | |||
| 433 | - | } | |||
| 434 | - | if string(got) != string(original) { | |||
| 435 | - | t.Fatalf("corrupt state bytes changed from %q to %q", original, got) | |||
| 436 | - | } | |||
| 437 | - | } | |||
| 438 | - | ||||
| 439 | - | func TestStateStoreSerializesConcurrentTransitions(t *testing.T) { | |||
| 440 | - | store := newStateStore() | |||
| 441 | - | if err := store.load(t.TempDir(), "/projects/concurrent"); err != nil { | |||
| 442 | - | t.Fatal(err) | |||
| 443 | - | } | |||
| 444 | - | const count = 12 | |||
| 445 | - | var wg sync.WaitGroup | |||
| 446 | - | for i := 0; i < count; i++ { | |||
| 447 | - | wg.Add(1) | |||
| 448 | - | go func(i int) { | |||
| 449 | - | defer wg.Done() | |||
| 450 | - | title := "phase " + string(rune('a'+i)) | |||
| 451 | - | _, _, err := store.transact(func(current PlanState) (PlanState, string, error) { | |||
| 452 | - | return applyAction(current, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr(title)}) | |||
| 453 | - | }) | |||
| 454 | - | if err != nil { | |||
| 455 | - | t.Errorf("concurrent transition: %v", err) | |||
| 456 | - | } | |||
| 457 | - | }(i) | |||
| 458 | - | } | |||
| 459 | - | wg.Wait() | |||
| 460 | - | state, err := store.snapshot() | |||
| 461 | - | if err != nil { | |||
| 462 | - | t.Fatal(err) | |||
| 463 | - | } | |||
| 464 | - | if len(state.Phases) != count { | |||
| 465 | - | t.Fatalf("serialized phase count = %d, want %d", len(state.Phases), count) | |||
| 466 | - | } | |||
| 467 | - | } | |||
| 468 | - | ||||
| 469 | - | func mustStat(t *testing.T, path string) os.FileInfo { | |||
| 470 | - | t.Helper() | |||
| 471 | - | info, err := os.Stat(path) | |||
| 472 | - | if err != nil { | |||
| 473 | - | t.Fatal(err) | |||
| 474 | - | } | |||
| 475 | - | return info | |||
| 476 | - | } | |||
D examples/skills/tasked-phases/SKILL.md +0 -37
| @@ -1,37 +0,0 @@ | |||||
| 1 | - | --- | |||
| 2 | - | name: tasked-phases | |||
| 3 | - | description: Use when the user wants a spec turned into phased work with checklist tasks tracked by the tasked_phases tool, or when ongoing work should be tracked phase by phase. | |||
| 4 | - | --- | |||
| 5 | - | ||||
| 6 | - | # Tasked Phases | |||
| 7 | - | ||||
| 8 | - | Use the `tasked_phases` tool as the source of truth for the spec, phases, current focus, and checklist progress. | |||
| 9 | - | ||||
| 10 | - | ## Workflow | |||
| 11 | - | ||||
| 12 | - | 1. Clarify missing requirements when the spec is ambiguous. | |||
| 13 | - | 2. Save the accepted spec with `tasked_phases` using `set_spec`. | |||
| 14 | - | 3. Create or materially revise the plan with `replace_plan`. | |||
| 15 | - | 4. Keep phases small and tasks concrete enough to check off. | |||
| 16 | - | 5. Set the focus with `set_current_phase` and pass the target `phaseId`. | |||
| 17 | - | 6. Call `set_task_checked` immediately after each checklist task is completed. | |||
| 18 | - | 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task. | |||
| 19 | - | 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases. | |||
| 20 | - | ||||
| 21 | - | Successful mutations return one progress line; use `get_status` or | |||
| 22 | - | `replace_plan` for details. `set_task_checked` changes one task; | |||
| 23 | - | `set_phase_checked` changes every task in a phase. | |||
| 24 | - | ||||
| 25 | - | ## Planning rules | |||
| 26 | - | ||||
| 27 | - | - Prefer 3–7 phases unless the task is tiny. | |||
| 28 | - | - Each task should describe an observable outcome. | |||
| 29 | - | - Avoid vague tasks such as “work on this” or “finish implementation”. | |||
| 30 | - | - Keep the stored spec and plan concise and operational. | |||
| 31 | - | ||||
| 32 | - | ## Tool guidance | |||
| 33 | - | ||||
| 34 | - | - Use `get_status` before relying on remembered state or revising a long-running plan. | |||
| 35 | - | - Use `replace_plan` for a substantial restructuring; use `add_*`, `update_*`, and `remove_*` for small changes. | |||
| 36 | - | - Pass `phaseId` as the raw ID shown in brackets, without the brackets. The tool also accepts a complete phase label or a unique phase title when recovering from copied output. | |||
| 37 | - | - Do not rely on prose alone for completion state; update the tool after each completed task. | |||
M packages/agent/build.go +3 -2
| @@ -100,7 +100,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||||
| 100 | // Web capability and other native names remain reserved even when their | 100 | // Web capability and other native names remain reserved even when their | ||
| 101 | // policy excludes the current session. An extension must not turn a | 101 | // policy excludes the current session. An extension must not turn a | ||
| 102 | // normal CLI opt-out into a differently implemented capability. | 102 | // normal CLI opt-out into a differently implemented capability. | ||
| 103 | - | if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "schedule" || info.Name == "update_goal" { | 103 | + | if tools.IsWebCapabilityName(info.Name) || info.Name == "grep" || info.Name == "schedule" || info.Name == tools.UpdateGoalToolName || info.Name == tools.UpdatePlanToolName { |
| 104 | continue | 104 | continue | ||
| 105 | } | 105 | } | ||
| 106 | if _, exists := r.ToolRegistry[info.Name]; exists { | 106 | if _, exists := r.ToolRegistry[info.Name]; exists { | ||
| @@ -1232,6 +1232,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||||
| 1232 | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | 1232 | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | ||
| 1233 | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, | 1233 | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, | ||
| 1234 | "update_goal": &tools.UpdateGoalTool{}, | 1234 | "update_goal": &tools.UpdateGoalTool{}, | ||
| 1235 | + | "update_plan": &tools.UpdatePlanTool{}, | |||
| 1235 | } | 1236 | } | ||
| 1236 | if webSearchAllowedForRegistry(args) { | 1237 | if webSearchAllowedForRegistry(args) { | ||
| 1237 | for name, tool := range tools.NewWebTools() { | 1238 | for name, tool := range tools.NewWebTools() { | ||
| @@ -1343,7 +1344,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||||
| 1343 | return false | 1344 | return false | ||
| 1344 | } | 1345 | } | ||
| 1345 | 1346 | ||||
| 1346 | - | var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal"} | 1347 | + | var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "bash", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"} |
| 1347 | 1348 | ||||
| 1348 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1349 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1349 | var out []ToolSummary | 1350 | var out []ToolSummary | ||
M packages/agent/build_grep_test.go +16 -0
| @@ -30,6 +30,22 @@ func TestBuildToolRegistryWebCapabilityBundle(t *testing.T) { | |||||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | func TestBuildToolRegistryIncludesUpdatePlanByDefaultAndExplicitSelection(t *testing.T) { | |||
| 34 | + | cwd := t.TempDir() | |||
| 35 | + | defaultRegistry := buildToolRegistry(Args{}, cwd, nil, false, false, false) | |||
| 36 | + | if _, ok := defaultRegistry[tools.UpdatePlanToolName].(*tools.UpdatePlanTool); !ok { | |||
| 37 | + | t.Fatalf("default update_plan has type %T", defaultRegistry[tools.UpdatePlanToolName]) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | selected := buildToolRegistry(Args{Tools: []string{tools.UpdatePlanToolName}}, cwd, nil, false, false, false) | |||
| 41 | + | if len(selected) != 1 { | |||
| 42 | + | t.Fatalf("selected registry has %d tools, want one", len(selected)) | |||
| 43 | + | } | |||
| 44 | + | if _, ok := selected[tools.UpdatePlanToolName].(*tools.UpdatePlanTool); !ok { | |||
| 45 | + | t.Fatalf("selected update_plan has type %T", selected[tools.UpdatePlanToolName]) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 33 | func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) { | 49 | func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) { | ||
| 34 | cwd := t.TempDir() | 50 | cwd := t.TempDir() | ||
| 35 | 51 | ||||
M packages/agent/ext/ext.go +8 -6
| @@ -422,9 +422,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||||
| 422 | // | 422 | // | ||
| 423 | // Conflicts with active built-in tool names (read, write, edit, bash, | 423 | // Conflicts with active built-in tool names (read, write, edit, bash, | ||
| 424 | // create_worktree, grep, lsp, web_search, web_open, web_find, web_click, | 424 | // create_worktree, grep, lsp, web_search, web_open, web_find, web_click, | ||
| 425 | - | // schedule, update_goal, and skill) are silently shadowed by the built-in. | 425 | + | // schedule, update_goal, update_plan, and skill) are silently shadowed by the |
| 426 | - | // Every native public-web name, plus grep, schedule, and update_goal, is | 426 | + | // built-in. Every native public-web name, plus grep, schedule, update_goal, and |
| 427 | - | // reserved even when unavailable, so an extension cannot claim or replace it. | 427 | + | // update_plan, is reserved even when unavailable, so an extension cannot claim |
| 428 | + | // or replace it. | |||
| 428 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | 429 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | ||
| 429 | e.registerTool(name, description, schema, false, fn) | 430 | e.registerTool(name, description, schema, false, fn) | ||
| 430 | } | 431 | } | ||
| @@ -527,13 +528,14 @@ const ( | |||||
| 527 | // WidgetPositionAboveInput keeps a widget in the persistent chrome above | 528 | // WidgetPositionAboveInput keeps a widget in the persistent chrome above | ||
| 528 | // the editor. It is the default for empty and unknown positions. | 529 | // the editor. It is the default for empty and unknown positions. | ||
| 529 | WidgetPositionAboveInput = extproto.WidgetPositionAboveInput | 530 | WidgetPositionAboveInput = extproto.WidgetPositionAboveInput | ||
| 530 | - | // WidgetPositionRightBar keeps a widget in the host-owned side rail when | 531 | + | // WidgetPositionRightBar is retained for source compatibility. Widgets use |
| 531 | - | // the terminal is wide enough; narrow terminals fall back above the input. | 532 | + | // the above-input placement because the right sidebar no longer exists. |
| 533 | + | // Deprecated: use WidgetPositionAboveInput. | |||
| 532 | WidgetPositionRightBar = extproto.WidgetPositionRightBar | 534 | WidgetPositionRightBar = extproto.WidgetPositionRightBar | ||
| 533 | ) | 535 | ) | ||
| 534 | 536 | ||||
| 535 | // SetWidget replaces one persistent widget owned by this extension. Use | 537 | // SetWidget replaces one persistent widget owned by this extension. Use | ||
| 536 | - | // WidgetPositionAboveInput or WidgetPositionRightBar for the position. | 538 | + | // WidgetPositionAboveInput for the position. |
| 537 | func (e *Extension) SetWidget(id, position, title string, lines []string) { | 539 | func (e *Extension) SetWidget(id, position, title string, lines []string) { | ||
| 538 | _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines}) | 540 | _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines}) | ||
| 539 | } | 541 | } | ||
M packages/agent/ext/ext_test.go +2 -2
| @@ -283,13 +283,13 @@ func TestPersistentChromeFrames(t *testing.T) { | |||||
| 283 | t.Fatalf("status = %+v", sf) | 283 | t.Fatalf("status = %+v", sf) | ||
| 284 | } | 284 | } | ||
| 285 | 285 | ||||
| 286 | - | h.ext.SetWidget("plan", WidgetPositionRightBar, "Plan", []string{"one"}) | 286 | + | h.ext.SetWidget("plan", WidgetPositionAboveInput, "Plan", []string{"one"}) |
| 287 | widget := h.drainUntil(t, "widget") | 287 | widget := h.drainUntil(t, "widget") | ||
| 288 | var wf extproto.WidgetFromExt | 288 | var wf extproto.WidgetFromExt | ||
| 289 | if err := json.Unmarshal(widget.raw, &wf); err != nil { | 289 | if err := json.Unmarshal(widget.raw, &wf); err != nil { | ||
| 290 | t.Fatal(err) | 290 | t.Fatal(err) | ||
| 291 | } | 291 | } | ||
| 292 | - | if wf.ID != "plan" || wf.Position != WidgetPositionRightBar || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" { | 292 | + | if wf.ID != "plan" || wf.Position != WidgetPositionAboveInput || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" { |
| 293 | t.Fatalf("widget = %+v", wf) | 293 | t.Fatalf("widget = %+v", wf) | ||
| 294 | } | 294 | } | ||
| 295 | 295 | ||||
M packages/agent/extproto/extproto.go +8 -18
| @@ -17,10 +17,7 @@ | |||||
| 17 | // correlate; events and notifications never carry an ID. | 17 | // correlate; events and notifications never carry an ID. | ||
| 18 | package extproto | 18 | package extproto | ||
| 19 | 19 | ||||
| 20 | - | import ( | 20 | + | import "encoding/json" |
| 21 | - | "encoding/json" | |||
| 22 | - | "strings" | |||
| 23 | - | ) | |||
| 24 | 21 | ||||
| 25 | // ProtocolVersion is the extension protocol major. Version 2 introduces the | 22 | // ProtocolVersion is the extension protocol major. Version 2 introduces the | ||
| 26 | // zut_version wire key and requires both sides to reject mismatched majors. | 23 | // zut_version wire key and requires both sides to reject mismatched majors. | ||
| @@ -144,25 +141,18 @@ type StatusFromExt struct { | |||||
| 144 | 141 | ||||
| 145 | const ( | 142 | const ( | ||
| 146 | WidgetPositionAboveInput = "above_input" | 143 | WidgetPositionAboveInput = "above_input" | ||
| 147 | - | WidgetPositionRightBar = "right_bar" | 144 | + | // WidgetPositionRightBar is retained for source compatibility. The right |
| 145 | + | // sidebar no longer exists, so this placement resolves above the input. | |||
| 146 | + | WidgetPositionRightBar = WidgetPositionAboveInput | |||
| 148 | ) | 147 | ) | ||
| 149 | 148 | ||||
| 150 | - | // NormalizeWidgetPosition returns the host's canonical widget placement. | 149 | + | // NormalizeWidgetPosition keeps extension widgets in the single supported |
| 151 | - | // Empty and unknown values intentionally retain the historical above-input | 150 | + | // above-input placement. Unknown and removed placement hints remain compatible. |
| 152 | - | // behavior so older extensions and future placement hints remain compatible. | 151 | + | func NormalizeWidgetPosition(string) string { return WidgetPositionAboveInput } |
| 153 | - | func NormalizeWidgetPosition(position string) string { | |||
| 154 | - | switch strings.ToLower(strings.TrimSpace(position)) { | |||
| 155 | - | case WidgetPositionRightBar: | |||
| 156 | - | return WidgetPositionRightBar | |||
| 157 | - | default: | |||
| 158 | - | return WidgetPositionAboveInput | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | 152 | ||||
| 162 | // WidgetFromExt sets or replaces one persistent widget owned by an | 153 | // WidgetFromExt sets or replaces one persistent widget owned by an | ||
| 163 | // extension. Position is a host-defined placement hint. Interactive hosts | 154 | // extension. Position is a host-defined placement hint. Interactive hosts | ||
| 164 | - | // support "above_input" and "right_bar"; empty and unknown values retain | 155 | + | // render widgets above the input; empty and unknown values use that placement. |
| 165 | - | // the historical "above_input" behavior. | |||
| 166 | type WidgetFromExt struct { | 156 | type WidgetFromExt struct { | ||
| 167 | Type string `json:"type"` | 157 | Type string `json:"type"` | ||
| 168 | ID string `json:"id"` | 158 | ID string `json:"id"` | ||
M packages/agent/extproto/widget_test.go +8 -9
| @@ -7,11 +7,10 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | func TestNormalizeWidgetPosition(t *testing.T) { | 8 | func TestNormalizeWidgetPosition(t *testing.T) { | ||
| 9 | cases := map[string]string{ | 9 | cases := map[string]string{ | ||
| 10 | - | WidgetPositionRightBar: WidgetPositionRightBar, | 10 | + | WidgetPositionRightBar: WidgetPositionAboveInput, |
| 11 | - | " RIGHT_BAR ": WidgetPositionRightBar, | 11 | + | "right_bar": WidgetPositionAboveInput, |
| 12 | - | WidgetPositionAboveInput: WidgetPositionAboveInput, | 12 | + | "": WidgetPositionAboveInput, |
| 13 | - | "": WidgetPositionAboveInput, | 13 | + | "unknown": WidgetPositionAboveInput, |
| 14 | - | "unknown": WidgetPositionAboveInput, | |||
| 15 | } | 14 | } | ||
| 16 | for input, want := range cases { | 15 | for input, want := range cases { | ||
| 17 | if got := NormalizeWidgetPosition(input); got != want { | 16 | if got := NormalizeWidgetPosition(input); got != want { | ||
| @@ -29,11 +28,11 @@ func TestWidgetFrameKeepsUnknownAndMissingPositionsCompatible(t *testing.T) { | |||||
| 29 | t.Fatalf("missing position normalized to %q, want %q", got, WidgetPositionAboveInput) | 28 | t.Fatalf("missing position normalized to %q, want %q", got, WidgetPositionAboveInput) | ||
| 30 | } | 29 | } | ||
| 31 | 30 | ||||
| 32 | - | var rightBar WidgetFromExt | 31 | + | var removedPlacement WidgetFromExt |
| 33 | - | if err := json.Unmarshal([]byte(`{"type":"widget","id":"plan","position":"right_bar","title":"Plan"}`), &rightBar); err != nil { | 32 | + | if err := json.Unmarshal([]byte(`{"type":"widget","id":"plan","position":"right_bar","title":"Plan"}`), &removedPlacement); err != nil { |
| 34 | t.Fatal(err) | 33 | t.Fatal(err) | ||
| 35 | } | 34 | } | ||
| 36 | - | if got := NormalizeWidgetPosition(rightBar.Position); got != WidgetPositionRightBar { | 35 | + | if got := NormalizeWidgetPosition(removedPlacement.Position); got != WidgetPositionAboveInput { |
| 37 | - | t.Fatalf("right_bar position normalized to %q, want %q", got, WidgetPositionRightBar) | 36 | + | t.Fatalf("removed right_bar position normalized to %q, want %q", got, WidgetPositionAboveInput) |
| 38 | } | 37 | } | ||
| 39 | } | 38 | } | ||
M packages/agent/modes/ext_notes_test.go +12 -92
| @@ -1,7 +1,6 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | "fmt" | 4 | "fmt" | ||
| 6 | "strings" | 5 | "strings" | ||
| 7 | "testing" | 6 | "testing" | ||
| @@ -86,32 +85,10 @@ func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) { | |||||
| 86 | } | 85 | } | ||
| 87 | } | 86 | } | ||
| 88 | 87 | ||||
| 89 | - | func TestNarrowRightBarFallbackCapsCombinedExtensionChrome(t *testing.T) { | |||
| 90 | - | i := newNotesTestInteractive() | |||
| 91 | - | for n := 0; n < 3; n++ { | |||
| 92 | - | lines := make([]string, 4) | |||
| 93 | - | for row := range lines { | |||
| 94 | - | lines[row] = fmt.Sprintf("extension-%d-line-%d", n, row) | |||
| 95 | - | } | |||
| 96 | - | i.SetWidget(fmt.Sprintf("extension-%d", n), "plan", "right_bar", "Plan", lines) | |||
| 97 | - | } | |||
| 98 | - | i.SetStatus("status-extension", "progress", "info", "still visible") | |||
| 99 | - | ||||
| 100 | - | i.mu.Lock() | |||
| 101 | - | got := i.extensionChromeLinesForLayoutLocked(72, false, true) | |||
| 102 | - | i.mu.Unlock() | |||
| 103 | - | if len(got) != maxNarrowExtensionChromeRows { | |||
| 104 | - | t.Fatalf("narrow fallback rows = %d, want %d: %v", len(got), maxNarrowExtensionChromeRows, got) | |||
| 105 | - | } | |||
| 106 | - | if !strings.Contains(strings.Join(got, "\n"), "extension chrome truncated") { | |||
| 107 | - | t.Fatalf("narrow fallback omitted truncation marker: %v", got) | |||
| 108 | - | } | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | func TestPersistentExtensionStatusAndWidget(t *testing.T) { | 88 | func TestPersistentExtensionStatusAndWidget(t *testing.T) { | ||
| 112 | i := newNotesTestInteractive() | 89 | i := newNotesTestInteractive() | ||
| 113 | - | i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked") | 90 | + | i.SetStatus("sample", "progress", "success", "2/4 tasks checked") |
| 114 | - | i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"}) | 91 | + | i.SetWidget("sample", "plan", "above_input", "Plan", []string{"Current phase: parse", "[ ] read files"}) |
| 115 | 92 | ||||
| 116 | i.mu.Lock() | 93 | i.mu.Lock() | ||
| 117 | lines := i.extensionChromeLinesLocked(80) | 94 | lines := i.extensionChromeLinesLocked(80) | ||
| @@ -119,55 +96,29 @@ func TestPersistentExtensionStatusAndWidget(t *testing.T) { | |||||
| 119 | widgetKey := i.extensionWidgetsKeyLocked() | 96 | widgetKey := i.extensionWidgetsKeyLocked() | ||
| 120 | i.mu.Unlock() | 97 | i.mu.Unlock() | ||
| 121 | joined := strings.Join(lines, "\n") | 98 | joined := strings.Join(lines, "\n") | ||
| 122 | - | if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Tasked phases") || !strings.Contains(joined, "[ ] read files") { | 99 | + | if !strings.Contains(joined, "2/4 tasks checked") || !strings.Contains(joined, "Plan") || !strings.Contains(joined, "[ ] read files") { |
| 123 | t.Fatalf("persistent extension chrome = %q", joined) | 100 | t.Fatalf("persistent extension chrome = %q", joined) | ||
| 124 | } | 101 | } | ||
| 125 | - | if !strings.Contains(statusKey, "tasked-phases/progress/success/2/4 tasks checked") { | 102 | + | if !strings.Contains(statusKey, "sample/progress/success/2/4 tasks checked") { |
| 126 | t.Fatalf("status cache key = %q", statusKey) | 103 | t.Fatalf("status cache key = %q", statusKey) | ||
| 127 | } | 104 | } | ||
| 128 | - | if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") { | 105 | + | if !strings.Contains(widgetKey, "sample/plan/above_input/Plan") { |
| 129 | t.Fatalf("widget cache key = %q", widgetKey) | 106 | t.Fatalf("widget cache key = %q", widgetKey) | ||
| 130 | } | 107 | } | ||
| 131 | 108 | ||||
| 132 | - | i.SetStatus("tasked-phases", "progress", "", "") | 109 | + | i.SetStatus("sample", "progress", "", "") |
| 133 | - | i.ClearWidget("tasked-phases", "plan") | 110 | + | i.ClearWidget("sample", "plan") |
| 134 | if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 { | 111 | if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 { | ||
| 135 | t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets) | 112 | t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets) | ||
| 136 | } | 113 | } | ||
| 137 | } | 114 | } | ||
| 138 | 115 | ||||
| 139 | - | func TestRightBarWidgetsAreDeterministicAndFallbackAboveInput(t *testing.T) { | |||
| 140 | - | i := newNotesTestInteractive() | |||
| 141 | - | i.SetWidget("zeta", "plan", "right_bar", "Zeta", []string{"zeta line"}) | |||
| 142 | - | i.SetWidget("alpha", "second", "right_bar", "Second", []string{"second line"}) | |||
| 143 | - | i.SetWidget("alpha", "first", "right_bar", "First", []string{"first line"}) | |||
| 144 | - | i.SetWidget("legacy", "plan", "above_input", "Legacy", []string{"legacy line"}) | |||
| 145 | - | ||||
| 146 | - | i.mu.Lock() | |||
| 147 | - | widgets := i.rightBarWidgetsLocked() | |||
| 148 | - | wideAbove := i.extensionChromeLinesAtLocked(80, true) | |||
| 149 | - | narrowFallback := i.extensionChromeLinesAtLocked(72, false) | |||
| 150 | - | i.mu.Unlock() | |||
| 151 | - | ||||
| 152 | - | if len(widgets) != 3 || widgets[0].Extension != "alpha" || widgets[0].ID != "first" || widgets[1].ID != "second" || widgets[2].Extension != "zeta" { | |||
| 153 | - | t.Fatalf("right-bar ordering = %+v", widgets) | |||
| 154 | - | } | |||
| 155 | - | wideText := strings.Join(wideAbove, "\n") | |||
| 156 | - | if strings.Contains(wideText, "Zeta") || strings.Contains(wideText, "Second") || strings.Contains(wideText, "First") { | |||
| 157 | - | t.Fatalf("right-bar widgets leaked into wide above-input chrome: %q", wideText) | |||
| 158 | - | } | |||
| 159 | - | fallbackText := strings.Join(narrowFallback, "\n") | |||
| 160 | - | if !strings.Contains(fallbackText, "Zeta") || !strings.Contains(fallbackText, "First") || !strings.Contains(fallbackText, "Legacy") { | |||
| 161 | - | t.Fatalf("narrow fallback omitted widgets: %q", fallbackText) | |||
| 162 | - | } | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) { | 116 | func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) { | ||
| 166 | i := newNotesTestInteractive() | 117 | i := newNotesTestInteractive() | ||
| 167 | i.SetStatus("gone", "progress", "success", "done") | 118 | i.SetStatus("gone", "progress", "success", "done") | ||
| 168 | - | i.SetWidget("gone", "plan", "right_bar", "Gone", []string{"old"}) | 119 | + | i.SetWidget("gone", "plan", "above_input", "Gone", []string{"old"}) |
| 169 | i.SetStatus("keep", "progress", "info", "still here") | 120 | i.SetStatus("keep", "progress", "info", "still here") | ||
| 170 | - | i.SetWidget("keep", "plan", "right_bar", "Keep", []string{"new"}) | 121 | + | i.SetWidget("keep", "plan", "above_input", "Keep", []string{"new"}) |
| 171 | i.extNotes = []string{"[gone] old note", "[keep] keep note"} | 122 | i.extNotes = []string{"[gone] old note", "[keep] keep note"} | ||
| 172 | 123 | ||||
| 173 | i.ClearExtensionChrome("gone") | 124 | i.ClearExtensionChrome("gone") | ||
| @@ -189,46 +140,15 @@ func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) { | |||||
| 189 | } | 140 | } | ||
| 190 | } | 141 | } | ||
| 191 | 142 | ||||
| 192 | - | func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) { | 143 | + | func TestInteractiveRedrawPlacesWidgetAboveInput(t *testing.T) { |
| 193 | term := &alertTestTerminal{} | 144 | term := &alertTestTerminal{} | ||
| 194 | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | 145 | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | ||
| 195 | i.rend.Resize(80, 24) | 146 | i.rend.Resize(80, 24) | ||
| 196 | - | i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"}) | 147 | + | i.SetWidget("sample", "plan", "above_input", "Plan", []string{"[ ] read files"}) |
| 197 | i.redraw() | 148 | i.redraw() | ||
| 198 | 149 | ||||
| 199 | out := stripANSIBytes(term.String()) | 150 | out := stripANSIBytes(term.String()) | ||
| 200 | if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") { | 151 | if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") { | ||
| 201 | - | t.Fatalf("wide redraw omitted right-bar content: %q", out) | 152 | + | t.Fatalf("redraw omitted above-input widget: %q", out) |
| 202 | - | } | |||
| 203 | - | if !strings.Contains(out, "│") { | |||
| 204 | - | t.Fatal("wide redraw omitted the main-pane divider") | |||
| 205 | - | } | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func TestCtrlBTogglesRightBarAndFallsBackAboveInput(t *testing.T) { | |||
| 209 | - | term := &alertTestTerminal{} | |||
| 210 | - | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | |||
| 211 | - | i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"}) | |||
| 212 | - | ||||
| 213 | - | if i.rightBarHidden { | |||
| 214 | - | t.Fatal("right bar starts hidden") | |||
| 215 | - | } | |||
| 216 | - | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB}); done { | |||
| 217 | - | t.Fatal("ctrl+b unexpectedly exited interactive mode") | |||
| 218 | - | } | |||
| 219 | - | if !i.rightBarHidden { | |||
| 220 | - | t.Fatal("ctrl+b did not hide the right bar") | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | i.mu.Lock() | |||
| 224 | - | fallback := strings.Join(i.extensionChromeLinesForLayoutLocked(80, false, true), "\n") | |||
| 225 | - | i.mu.Unlock() | |||
| 226 | - | if !strings.Contains(fallback, "Plan") { | |||
| 227 | - | t.Fatalf("hidden right-bar widget did not fall back above input: %q", fallback) | |||
| 228 | - | } | |||
| 229 | - | ||||
| 230 | - | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB}) | |||
| 231 | - | if i.rightBarHidden { | |||
| 232 | - | t.Fatal("second ctrl+b did not show the right bar") | |||
| 233 | } | 153 | } | ||
| 234 | } | 154 | } | ||
M packages/agent/modes/help.go +0 -1
| @@ -20,7 +20,6 @@ var helpKeyRows = [][2]string{ | |||||
| 20 | {"ctrl+u / ctrl+k", "delete to start / end of line"}, | 20 | {"ctrl+u / ctrl+k", "delete to start / end of line"}, | ||
| 21 | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | 21 | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | ||
| 22 | {"alt+← / alt+→", "jump one word back / forward"}, | 22 | {"alt+← / alt+→", "jump one word back / forward"}, | ||
| 23 | - | {"ctrl+b", "toggle the right sidebar"}, | |||
| 24 | {"ctrl+l", "redraw the screen"}, | 23 | {"ctrl+l", "redraw the screen"}, | ||
| 25 | {"ctrl+o", "expand / collapse long tool results"}, | 24 | {"ctrl+o", "expand / collapse long tool results"}, | ||
| 26 | {"pgup / pgdn", "scroll the chat one page up / down"}, | 25 | {"pgup / pgdn", "scroll the chat one page up / down"}, | ||
M packages/agent/modes/help_test.go +0 -3
| @@ -9,9 +9,6 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | ||
| 11 | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | 11 | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | ||
| 12 | - | if !strings.Contains(without, "ctrl+b") { | |||
| 13 | - | t.Fatalf("help omitted the right-sidebar shortcut: %q", without) | |||
| 14 | - | } | |||
| 15 | if strings.Contains(without, "/llama") { | 12 | if strings.Contains(without, "/llama") { | ||
| 16 | t.Fatalf("help exposed /llama without login: %q", without) | 13 | t.Fatalf("help exposed /llama without login: %q", without) | ||
| 17 | } | 14 | } | ||
M packages/agent/modes/interactive.go +0 -5
| @@ -702,7 +702,6 @@ type Interactive struct { | |||||
| 702 | reloadStatusSeq uint64 | 702 | reloadStatusSeq uint64 | ||
| 703 | extStatuses map[string]map[string]extensionStatus | 703 | extStatuses map[string]map[string]extensionStatus | ||
| 704 | extWidgets map[string]map[string]extensionWidget | 704 | extWidgets map[string]map[string]extensionWidget | ||
| 705 | - | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | |||
| 706 | liveBlock []string // live streaming/tool progress rendered outside scrollback | 705 | liveBlock []string // live streaming/tool progress rendered outside scrollback | ||
| 707 | helpBlock []string // rendered above the chat when /help was typed | 706 | helpBlock []string // rendered above the chat when /help was typed | ||
| 708 | sessionInfoBlocks []sessionInfoBlock | 707 | sessionInfoBlocks []sessionInfoBlock | ||
| @@ -1616,10 +1615,6 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1616 | const maxExtensionWidgetRows = 12 | 1615 | const maxExtensionWidgetRows = 12 | ||
| 1617 | const maxExtensionStatusRows = 6 | 1616 | const maxExtensionStatusRows = 6 | ||
| 1618 | 1617 | ||||
| 1619 | - | // maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back | |||
| 1620 | - | // above the input, leaving one row for the truncation marker. | |||
| 1621 | - | const maxNarrowExtensionChromeRows = 6 | |||
| 1622 | - | ||||
| 1623 | // sessionsRoot returns the session namespace for this run. | 1618 | // sessionsRoot returns the session namespace for this run. | ||
| 1624 | 1619 | ||||
| 1625 | // lastCols returns the current terminal width in columns. | 1620 | // lastCols returns the current terminal width in columns. | ||
M packages/agent/modes/interactive_input_routing.go +0 -7
| @@ -637,13 +637,6 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 637 | if i.ed.IsEmpty() && !i.busy { | 637 | if i.ed.IsEmpty() && !i.busy { | ||
| 638 | return true | 638 | return true | ||
| 639 | } | 639 | } | ||
| 640 | - | case tui.KeyCtrlB: | |||
| 641 | - | i.mu.Lock() | |||
| 642 | - | i.rightBarHidden = !i.rightBarHidden | |||
| 643 | - | i.mu.Unlock() | |||
| 644 | - | i.requestRendererInvalidate() | |||
| 645 | - | i.invalidate() | |||
| 646 | - | return false | |||
| 647 | case tui.KeyCtrlL: | 640 | case tui.KeyCtrlL: | ||
| 648 | i.requestRendererClear() | 641 | i.requestRendererClear() | ||
| 649 | i.invalidate() | 642 | i.invalidate() | ||
M packages/agent/modes/interactive_rendering.go +1 -65
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/zut/packages/agent/extproto" | |||
| 10 | "github.com/bnema/zut/packages/agent/skills" | 9 | "github.com/bnema/zut/packages/agent/skills" | ||
| 11 | "github.com/bnema/zut/packages/core" | 10 | "github.com/bnema/zut/packages/core" | ||
| 12 | "github.com/bnema/zut/packages/provider" | 11 | "github.com/bnema/zut/packages/provider" | ||
| @@ -122,38 +121,7 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||||
| 122 | } | 121 | } | ||
| 123 | return sb.String() | 122 | return sb.String() | ||
| 124 | } | 123 | } | ||
| 125 | - | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |||
| 126 | - | var out []tui.RightBarWidget | |||
| 127 | - | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |||
| 128 | - | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |||
| 129 | - | widget := i.extWidgets[extName][id] | |||
| 130 | - | if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar { | |||
| 131 | - | continue | |||
| 132 | - | } | |||
| 133 | - | out = append(out, tui.RightBarWidget{ | |||
| 134 | - | Extension: extName, | |||
| 135 | - | ID: id, | |||
| 136 | - | Title: widget.Title, | |||
| 137 | - | Lines: append([]string(nil), widget.Lines...), | |||
| 138 | - | }) | |||
| 139 | - | } | |||
| 140 | - | } | |||
| 141 | - | return out | |||
| 142 | - | } | |||
| 143 | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | 124 | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | ||
| 144 | - | return i.extensionChromeLinesAtLocked(cols, false) | |||
| 145 | - | } | |||
| 146 | - | func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string { | |||
| 147 | - | lines := i.extensionChromeLinesAtLocked(cols, rightBarActive) | |||
| 148 | - | if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows { | |||
| 149 | - | return lines | |||
| 150 | - | } | |||
| 151 | - | limit := maxNarrowExtensionChromeRows | |||
| 152 | - | trimmed := append([]string(nil), lines[:limit-1]...) | |||
| 153 | - | trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols))) | |||
| 154 | - | return trimmed | |||
| 155 | - | } | |||
| 156 | - | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |||
| 157 | var out []string | 125 | var out []string | ||
| 158 | bodyWidth := cols - 2 | 126 | bodyWidth := cols - 2 | ||
| 159 | if bodyWidth < 1 { | 127 | if bodyWidth < 1 { | ||
| @@ -178,9 +146,6 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool | |||||
| 178 | } | 146 | } | ||
| 179 | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | 147 | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | ||
| 180 | widget := i.extWidgets[extName][id] | 148 | widget := i.extWidgets[extName][id] | ||
| 181 | - | if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar { | |||
| 182 | - | continue | |||
| 183 | - | } | |||
| 184 | label := " [" + extName + "]" | 149 | label := " [" + extName + "]" | ||
| 185 | if widget.Title != "" { | 150 | if widget.Title != "" { | ||
| 186 | label += " " + widget.Title | 151 | label += " " + widget.Title | ||
| @@ -500,17 +465,6 @@ func (i *Interactive) redraw() { | |||||
| 500 | 465 | ||||
| 501 | cols, rows := i.cfg.Terminal.Size() | 466 | cols, rows := i.cfg.Terminal.Size() | ||
| 502 | mainCols := cols | 467 | mainCols := cols | ||
| 503 | - | rightBarWidgets := i.rightBarWidgetsLocked() | |||
| 504 | - | rightBarActive := false | |||
| 505 | - | var rightBarWidth int | |||
| 506 | - | if !i.rightBarHidden && len(rightBarWidgets) > 0 { | |||
| 507 | - | if width, rail, ok := tui.RightBarColumns(cols); ok { | |||
| 508 | - | mainCols = width | |||
| 509 | - | rightBarWidth = rail | |||
| 510 | - | rightBarActive = true | |||
| 511 | - | } | |||
| 512 | - | } | |||
| 513 | - | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |||
| 514 | i.renderOutsideLock = true | 468 | i.renderOutsideLock = true | ||
| 515 | chat := i.cachedChatLocked(mainCols) | 469 | chat := i.cachedChatLocked(mainCols) | ||
| 516 | i.renderOutsideLock = false | 470 | i.renderOutsideLock = false | ||
| @@ -783,7 +737,7 @@ func (i *Interactive) redraw() { | |||||
| 783 | queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint)) | 737 | queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint)) | ||
| 784 | } | 738 | } | ||
| 785 | 739 | ||||
| 786 | - | extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback) | 740 | + | extensionLines := i.extensionChromeLinesLocked(mainCols) |
| 787 | 741 | ||||
| 788 | // Bottom-sticky sections (always visible, never scroll). Each | 742 | // Bottom-sticky sections (always visible, never scroll). Each | ||
| 789 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | 743 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | ||
| @@ -1077,15 +1031,8 @@ func (i *Interactive) redraw() { | |||||
| 1077 | i.mu.Unlock() | 1031 | i.mu.Unlock() | ||
| 1078 | if modalBackdrop { | 1032 | if modalBackdrop { | ||
| 1079 | background := floatingBackgroundFrame(visibleChat, bottom, rows) | 1033 | background := floatingBackgroundFrame(visibleChat, bottom, rows) | ||
| 1080 | - | if rightBarActive { | |||
| 1081 | - | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |||
| 1082 | - | background = floatingRightBarFrame(theme, background, rightBar, mainCols, rightBarWidth) | |||
| 1083 | - | } | |||
| 1084 | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | 1034 | pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol) | ||
| 1085 | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | 1035 | i.rend.DrawFloating(pane, paneCursorRow, paneCursorCol) | ||
| 1086 | - | } else if rightBarActive { | |||
| 1087 | - | rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows) | |||
| 1088 | - | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |||
| 1089 | } else { | 1036 | } else { | ||
| 1090 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 1037 | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | ||
| 1091 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 1038 | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | ||
| @@ -1150,17 +1097,6 @@ func floatingBackgroundFrame(chat, bottom []string, rows int) []string { | |||||
| 1150 | return frame | 1097 | return frame | ||
| 1151 | } | 1098 | } | ||
| 1152 | 1099 | ||||
| 1153 | - | func floatingRightBarFrame(theme tui.Theme, main, rightBar []string, mainWidth, rightBarWidth int) []string { | |||
| 1154 | - | for row := range main { | |||
| 1155 | - | barLine := "" | |||
| 1156 | - | if row < len(rightBar) { | |||
| 1157 | - | barLine = rightBar[row] | |||
| 1158 | - | } | |||
| 1159 | - | main[row] = tui.JoinRightBar(theme, main[row], barLine, mainWidth, rightBarWidth) | |||
| 1160 | - | } | |||
| 1161 | - | return main | |||
| 1162 | - | } | |||
| 1163 | - | ||||
| 1164 | func hasImageEscape(line string) bool { | 1100 | func hasImageEscape(line string) bool { | ||
| 1165 | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | 1101 | return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G") | ||
| 1166 | } | 1102 | } | ||
M packages/agent/modes/json.go +7 -0
| @@ -109,6 +109,13 @@ func EventToJSON(ev core.AgentEvent) map[string]any { | |||||
| 109 | case core.EvToolProgress: | 109 | case core.EvToolProgress: | ||
| 110 | m["id"] = e.ID | 110 | m["id"] = e.ID | ||
| 111 | m["text"] = e.Text | 111 | m["text"] = e.Text | ||
| 112 | + | case core.EvPlanUpdate: | |||
| 113 | + | if e.Update.Explanation != nil { | |||
| 114 | + | m["explanation"] = *e.Update.Explanation | |||
| 115 | + | } else { | |||
| 116 | + | m["explanation"] = nil | |||
| 117 | + | } | |||
| 118 | + | m["plan"] = e.Update.Plan | |||
| 112 | case core.EvToolResult: | 119 | case core.EvToolResult: | ||
| 113 | m["id"] = e.ID | 120 | m["id"] = e.ID | ||
| 114 | m["is_error"] = e.Result.IsError | 121 | m["is_error"] = e.Result.IsError | ||
M packages/agent/modes/json_activity_test.go +19 -0
| @@ -83,6 +83,25 @@ func TestEventToJSONCacheDiagnosticsAreSanitized(t *testing.T) { | |||||
| 83 | } | 83 | } | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | + | func TestEventToJSONPlanUpdate(t *testing.T) { | |||
| 87 | + | explanation := "Discovery complete" | |||
| 88 | + | event := EventToJSON(core.EvPlanUpdate{Update: core.PlanUpdate{ | |||
| 89 | + | Explanation: &explanation, | |||
| 90 | + | Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}, | |||
| 91 | + | }}) | |||
| 92 | + | if event["type"] != "plan_update" || event["explanation"] != "Discovery complete" { | |||
| 93 | + | t.Fatalf("plan event = %#v", event) | |||
| 94 | + | } | |||
| 95 | + | plan, ok := event["plan"].([]core.PlanStep) | |||
| 96 | + | if !ok || len(plan) != 1 || plan[0].Status != core.PlanInProgress { | |||
| 97 | + | t.Fatalf("plan payload = %#v", event["plan"]) | |||
| 98 | + | } | |||
| 99 | + | withoutExplanation := EventToJSON(core.EvPlanUpdate{Update: core.PlanUpdate{Plan: []core.PlanStep{}}}) | |||
| 100 | + | if withoutExplanation["explanation"] != nil { | |||
| 101 | + | t.Fatalf("missing explanation = %#v, want null", withoutExplanation["explanation"]) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 86 | func TestEventToJSONActivityLifecycle(t *testing.T) { | 105 | func TestEventToJSONActivityLifecycle(t *testing.T) { | ||
| 87 | request := EventToJSON(core.EvRequestStarted{ | 106 | request := EventToJSON(core.EvRequestStarted{ | ||
| 88 | Provider: "anthropic", | 107 | Provider: "anthropic", | ||
M packages/agent/sdk/sdk.go +5 -6
| @@ -76,12 +76,11 @@ type Config struct { | |||||
| 76 | // BaseURL overrides the provider base url (for tests / proxies). | 76 | // BaseURL overrides the provider base url (for tests / proxies). | ||
| 77 | BaseURL string | 77 | BaseURL string | ||
| 78 | 78 | ||||
| 79 | - | // Tools is the list of tools to enable. Nil/empty = all ordinary built-in | 79 | + | // Tools is the list of tools to enable. Nil/empty enables the ordinary |
| 80 | - | // tools (read, write, edit, bash, create_worktree, grep, lsp). The public-web | 80 | + | // built-ins: read, write, edit, bash, create_worktree, grep, lsp, update_goal, |
| 81 | - | // capability is excluded unless this list explicitly contains "web_search"; | 81 | + | // and update_plan. The public-web capability is excluded unless this list |
| 82 | - | // that selector enables web_search, web_open, web_find, and web_click. Pass an | 82 | + | // explicitly contains "web_search"; that selector enables web_search, |
| 83 | - | // empty-but-non-nil slice | 83 | + | // web_open, web_find, and web_click. Pass NoTools=true to disable everything. |
| 84 | - | // (e.g. []string{}) plus NoTools=true to disable everything. | |||
| 85 | Tools []string | 84 | Tools []string | ||
| 86 | 85 | ||||
| 87 | // NoTools disables every tool. Useful for chat-only embeddings. | 86 | // NoTools disables every tool. Useful for chat-only embeddings. | ||
M packages/agent/sdk/types.go +17 -0
| @@ -42,6 +42,10 @@ type Event struct { | |||||
| 42 | IsError bool `json:"is_error,omitempty"` | 42 | IsError bool `json:"is_error,omitempty"` | ||
| 43 | Result []ContentBlock `json:"content,omitempty"` | 43 | Result []ContentBlock `json:"content,omitempty"` | ||
| 44 | 44 | ||||
| 45 | + | // plan_update | |||
| 46 | + | Explanation *string `json:"explanation,omitempty"` | |||
| 47 | + | Plan *[]PlanStep `json:"plan,omitempty"` | |||
| 48 | + | ||||
| 45 | // usage | 49 | // usage | ||
| 46 | Usage *Usage `json:"usage,omitempty"` | 50 | Usage *Usage `json:"usage,omitempty"` | ||
| 47 | Cumulative *Usage `json:"cumulative,omitempty"` | 51 | Cumulative *Usage `json:"cumulative,omitempty"` | ||
| @@ -59,6 +63,12 @@ type Event struct { | |||||
| 59 | Error string `json:"error,omitempty"` | 63 | Error string `json:"error,omitempty"` | ||
| 60 | } | 64 | } | ||
| 61 | 65 | ||||
| 66 | + | // PlanStep is one model-maintained task in a plan update. | |||
| 67 | + | type PlanStep struct { | |||
| 68 | + | Step string `json:"step"` | |||
| 69 | + | Status string `json:"status"` | |||
| 70 | + | } | |||
| 71 | + | ||||
| 62 | // Message is one transcript entry — a user prompt, assistant reply, | 72 | // Message is one transcript entry — a user prompt, assistant reply, | ||
| 63 | // or tool result. | 73 | // or tool result. | ||
| 64 | type Message struct { | 74 | type Message struct { | ||
| @@ -189,6 +199,13 @@ func toEvent(ev core.AgentEvent) Event { | |||||
| 189 | case core.EvToolProgress: | 199 | case core.EvToolProgress: | ||
| 190 | out.ID = e.ID | 200 | out.ID = e.ID | ||
| 191 | out.Text = e.Text | 201 | out.Text = e.Text | ||
| 202 | + | case core.EvPlanUpdate: | |||
| 203 | + | out.Explanation = e.Update.Explanation | |||
| 204 | + | plan := make([]PlanStep, len(e.Update.Plan)) | |||
| 205 | + | for idx, step := range e.Update.Plan { | |||
| 206 | + | plan[idx] = PlanStep{Step: step.Step, Status: string(step.Status)} | |||
| 207 | + | } | |||
| 208 | + | out.Plan = &plan | |||
| 192 | case core.EvToolResult: | 209 | case core.EvToolResult: | ||
| 193 | out.ID = e.ID | 210 | out.ID = e.ID | ||
| 194 | out.IsError = e.Result.IsError | 211 | out.IsError = e.Result.IsError | ||
M packages/agent/sdk/types_test.go +26 -0
| @@ -35,6 +35,32 @@ func TestToEventPreservesCacheDiagnostics(t *testing.T) { | |||||
| 35 | } | 35 | } | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | + | func TestToEventPreservesPlanUpdate(t *testing.T) { | |||
| 39 | + | explanation := "Discovery complete" | |||
| 40 | + | event := toEvent(core.EvPlanUpdate{Update: core.PlanUpdate{ | |||
| 41 | + | Explanation: &explanation, | |||
| 42 | + | Plan: []core.PlanStep{ | |||
| 43 | + | {Step: "Implement", Status: core.PlanInProgress}, | |||
| 44 | + | {Step: "Verify", Status: core.PlanPending}, | |||
| 45 | + | }, | |||
| 46 | + | }}) | |||
| 47 | + | if event.Type != "plan_update" || event.Explanation == nil || *event.Explanation != explanation { | |||
| 48 | + | t.Fatalf("plan event = %#v", event) | |||
| 49 | + | } | |||
| 50 | + | if event.Plan == nil || len(*event.Plan) != 2 || (*event.Plan)[0].Status != "in_progress" { | |||
| 51 | + | t.Fatalf("plan payload = %#v", event.Plan) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | empty := toEvent(core.EvPlanUpdate{Update: core.PlanUpdate{Plan: []core.PlanStep{}}}) | |||
| 55 | + | encoded, err := json.Marshal(empty) | |||
| 56 | + | if err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | if !strings.Contains(string(encoded), `"plan":[]`) { | |||
| 60 | + | t.Fatalf("empty plan JSON = %s, want explicit empty list", encoded) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 38 | func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) { | 64 | func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) { | ||
| 39 | retry := toEvent(core.EvRetryScheduled{ | 65 | retry := toEvent(core.EvRetryScheduled{ | ||
| 40 | Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond, | 66 | Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond, | ||
A packages/agent/tools/update_plan.go +84 -0
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "io" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/core" | |||
| 10 | + | "github.com/bnema/zut/packages/provider" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | UpdatePlanToolName = "update_plan" | |||
| 15 | + | updatePlanSchema = `{"type":"object","properties":{"explanation":{"type":"string","description":"Optional explanation for this plan update."},"plan":{"type":"array","description":"The list of steps.","items":{"type":"object","properties":{"step":{"type":"string","description":"Task step text."},"status":{"type":"string","enum":["pending","in_progress","completed"],"description":"Step status."}},"required":["step","status"],"additionalProperties":false}}},"required":["plan"],"additionalProperties":false}` | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // UpdatePlanTool lets the model maintain a turn-scoped task checklist. | |||
| 19 | + | type UpdatePlanTool struct{} | |||
| 20 | + | ||||
| 21 | + | var _ core.ToolArgumentRewritePolicy = (*UpdatePlanTool)(nil) | |||
| 22 | + | ||||
| 23 | + | type updatePlanArgs struct { | |||
| 24 | + | Explanation *string `json:"explanation"` | |||
| 25 | + | Plan *[]updatePlanStep `json:"plan"` | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type updatePlanStep struct { | |||
| 29 | + | Step *string `json:"step"` | |||
| 30 | + | Status *string `json:"status"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (t *UpdatePlanTool) Name() string { return UpdatePlanToolName } | |||
| 34 | + | ||||
| 35 | + | // AllowArgumentRewrite keeps the displayed checklist identical to the plan the | |||
| 36 | + | // model submitted. Guards may still allow or refuse the call. | |||
| 37 | + | func (t *UpdatePlanTool) AllowArgumentRewrite() bool { return false } | |||
| 38 | + | ||||
| 39 | + | func (t *UpdatePlanTool) Description() string { | |||
| 40 | + | return "Track steps and progress for complex, ambiguous, or multi-phase work. Skip trivial tasks and keep the plan current as work progresses." | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (t *UpdatePlanTool) Schema() json.RawMessage { return json.RawMessage(updatePlanSchema) } | |||
| 44 | + | ||||
| 45 | + | func (t *UpdatePlanTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |||
| 46 | + | var args updatePlanArgs | |||
| 47 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) | |||
| 48 | + | decoder.DisallowUnknownFields() | |||
| 49 | + | if err := decoder.Decode(&args); err != nil { | |||
| 50 | + | return updatePlanError(), nil | |||
| 51 | + | } | |||
| 52 | + | if err := decoder.Decode(&struct{}{}); err != io.EOF || args.Plan == nil { | |||
| 53 | + | return updatePlanError(), nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | update := core.PlanUpdate{ | |||
| 57 | + | Explanation: args.Explanation, | |||
| 58 | + | Plan: make([]core.PlanStep, len(*args.Plan)), | |||
| 59 | + | } | |||
| 60 | + | for idx, step := range *args.Plan { | |||
| 61 | + | if step.Step == nil || step.Status == nil { | |||
| 62 | + | return updatePlanError(), nil | |||
| 63 | + | } | |||
| 64 | + | status := core.PlanStepStatus(*step.Status) | |||
| 65 | + | switch status { | |||
| 66 | + | case core.PlanPending, core.PlanInProgress, core.PlanCompleted: | |||
| 67 | + | default: | |||
| 68 | + | return updatePlanError(), nil | |||
| 69 | + | } | |||
| 70 | + | update.Plan[idx] = core.PlanStep{Step: *step.Step, Status: status} | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | return core.ToolResult{ | |||
| 74 | + | Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}, | |||
| 75 | + | Details: update, | |||
| 76 | + | }, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func updatePlanError() core.ToolResult { | |||
| 80 | + | return core.ToolResult{ | |||
| 81 | + | Content: []provider.Content{provider.TextBlock{Text: "invalid arguments"}}, | |||
| 82 | + | IsError: true, | |||
| 83 | + | } | |||
| 84 | + | } | |||
A packages/agent/tools/update_plan_test.go +95 -0
| @@ -0,0 +1,95 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/core" | |||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestUpdatePlanToolReturnsPlanUpdate(t *testing.T) { | |||
| 13 | + | tool := &UpdatePlanTool{} | |||
| 14 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{ | |||
| 15 | + | "explanation":"Finished discovery", | |||
| 16 | + | "plan":[ | |||
| 17 | + | {"step":"Map the implementation","status":"completed"}, | |||
| 18 | + | {"step":"Add the built-in tool","status":"in_progress"}, | |||
| 19 | + | {"step":"Verify the behavior","status":"pending"} | |||
| 20 | + | ] | |||
| 21 | + | }`), nil) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | if result.IsError { | |||
| 26 | + | t.Fatalf("result = %#v, want success", result) | |||
| 27 | + | } | |||
| 28 | + | update, ok := result.Details.(core.PlanUpdate) | |||
| 29 | + | if !ok { | |||
| 30 | + | t.Fatalf("details = %T, want core.PlanUpdate", result.Details) | |||
| 31 | + | } | |||
| 32 | + | if update.Explanation == nil || *update.Explanation != "Finished discovery" || len(update.Plan) != 3 { | |||
| 33 | + | t.Fatalf("update = %#v", update) | |||
| 34 | + | } | |||
| 35 | + | if update.Plan[1].Status != core.PlanInProgress { | |||
| 36 | + | t.Fatalf("second status = %q, want %q", update.Plan[1].Status, core.PlanInProgress) | |||
| 37 | + | } | |||
| 38 | + | if len(result.Content) != 1 { | |||
| 39 | + | t.Fatalf("content = %#v, want one text block", result.Content) | |||
| 40 | + | } | |||
| 41 | + | text, ok := result.Content[0].(provider.TextBlock) | |||
| 42 | + | if !ok || text.Text != "Plan updated" { | |||
| 43 | + | t.Fatalf("content = %#v, want Plan updated", result.Content) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestUpdatePlanToolAcceptsEmptyPlan(t *testing.T) { | |||
| 48 | + | result, err := (&UpdatePlanTool{}).Execute(context.Background(), json.RawMessage(`{"plan":[]}`), nil) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | if result.IsError { | |||
| 53 | + | t.Fatalf("result = %#v, want success", result) | |||
| 54 | + | } | |||
| 55 | + | update, ok := result.Details.(core.PlanUpdate) | |||
| 56 | + | if !ok || update.Explanation != nil || len(update.Plan) != 0 { | |||
| 57 | + | t.Fatalf("details = %#v, want empty plan update without explanation", result.Details) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestUpdatePlanToolAcceptsMultipleInProgressSteps(t *testing.T) { | |||
| 62 | + | result, err := (&UpdatePlanTool{}).Execute(context.Background(), json.RawMessage(`{"plan":[{"step":"one","status":"in_progress"},{"step":"two","status":"in_progress"}]}`), nil) | |||
| 63 | + | if err != nil { | |||
| 64 | + | t.Fatal(err) | |||
| 65 | + | } | |||
| 66 | + | if result.IsError { | |||
| 67 | + | t.Fatalf("result = %#v, want Codex-compatible acceptance", result) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestUpdatePlanToolRejectsMalformedArguments(t *testing.T) { | |||
| 72 | + | tests := []string{ | |||
| 73 | + | `{}`, | |||
| 74 | + | `{"plan":[{"status":"pending"}]}`, | |||
| 75 | + | `{"plan":[{"step":"work"}]}`, | |||
| 76 | + | `{"plan":[{"step":"work","status":"unknown"}]}`, | |||
| 77 | + | `{"plan":[],"unexpected":true}`, | |||
| 78 | + | `{"plan":[{"step":"work","status":"pending","unexpected":true}]}`, | |||
| 79 | + | `{"plan":[]} trailing`, | |||
| 80 | + | } | |||
| 81 | + | for _, raw := range tests { | |||
| 82 | + | t.Run(raw, func(t *testing.T) { | |||
| 83 | + | result, err := (&UpdatePlanTool{}).Execute(context.Background(), json.RawMessage(raw), nil) | |||
| 84 | + | if err != nil { | |||
| 85 | + | t.Fatal(err) | |||
| 86 | + | } | |||
| 87 | + | if !result.IsError { | |||
| 88 | + | t.Fatalf("result = %#v, want tool error", result) | |||
| 89 | + | } | |||
| 90 | + | if _, ok := result.Details.(core.PlanUpdate); ok { | |||
| 91 | + | t.Fatalf("invalid result exposed plan update: %#v", result.Details) | |||
| 92 | + | } | |||
| 93 | + | }) | |||
| 94 | + | } | |||
| 95 | + | } | |||
M packages/core/agent.go +9 -0
| @@ -1301,6 +1301,9 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||||
| 1301 | if a.OnToolResult != nil { | 1301 | if a.OnToolResult != nil { | ||
| 1302 | a.OnToolResult(tc.ID, res) | 1302 | a.OnToolResult(tc.ID, res) | ||
| 1303 | } | 1303 | } | ||
| 1304 | + | if update, ok := res.Details.(PlanUpdate); ok && !res.IsError { | |||
| 1305 | + | sink(EvPlanUpdate{Update: update}) | |||
| 1306 | + | } | |||
| 1304 | sink(EvToolResult{ID: tc.ID, Result: res}) | 1307 | sink(EvToolResult{ID: tc.ID, Result: res}) | ||
| 1305 | } | 1308 | } | ||
| 1306 | 1309 | ||||
| @@ -1356,6 +1359,12 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools | |||||
| 1356 | } | 1359 | } | ||
| 1357 | } | 1360 | } | ||
| 1358 | if len(modified) > 0 && json.Valid(modified) { | 1361 | if len(modified) > 0 && json.Valid(modified) { | ||
| 1362 | + | if policy, ok := tool.(ToolArgumentRewritePolicy); ok && !policy.AllowArgumentRewrite() { | |||
| 1363 | + | return ToolResult{ | |||
| 1364 | + | Content: []provider.Content{provider.TextBlock{Text: "tool arguments cannot be rewritten"}}, | |||
| 1365 | + | IsError: true, | |||
| 1366 | + | } | |||
| 1367 | + | } | |||
| 1359 | args = modified | 1368 | args = modified | ||
| 1360 | } | 1369 | } | ||
| 1361 | } | 1370 | } | ||
M packages/core/events.go +28 -0
| @@ -171,6 +171,34 @@ type EvToolProgress struct { | |||||
| 171 | 171 | ||||
| 172 | func (EvToolProgress) Type() string { return "tool_progress" } | 172 | func (EvToolProgress) Type() string { return "tool_progress" } | ||
| 173 | 173 | ||||
| 174 | + | // PlanStepStatus is the state of one update_plan checklist step. | |||
| 175 | + | type PlanStepStatus string | |||
| 176 | + | ||||
| 177 | + | const ( | |||
| 178 | + | PlanPending PlanStepStatus = "pending" | |||
| 179 | + | PlanInProgress PlanStepStatus = "in_progress" | |||
| 180 | + | PlanCompleted PlanStepStatus = "completed" | |||
| 181 | + | ) | |||
| 182 | + | ||||
| 183 | + | // PlanStep is one model-maintained task in a plan update. | |||
| 184 | + | type PlanStep struct { | |||
| 185 | + | Step string `json:"step"` | |||
| 186 | + | Status PlanStepStatus `json:"status"` | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // PlanUpdate replaces the model's current task checklist for a turn. | |||
| 190 | + | type PlanUpdate struct { | |||
| 191 | + | Explanation *string `json:"explanation"` | |||
| 192 | + | Plan []PlanStep `json:"plan"` | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // EvPlanUpdate reports a successful update_plan call before its tool result. | |||
| 196 | + | type EvPlanUpdate struct { | |||
| 197 | + | Update PlanUpdate | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (EvPlanUpdate) Type() string { return "plan_update" } | |||
| 201 | + | ||||
| 174 | type EvToolResult struct { | 202 | type EvToolResult struct { | ||
| 175 | ID string | 203 | ID string | ||
| 176 | Result ToolResult | 204 | Result ToolResult | ||
M packages/core/intercept_test.go +55 -3
| @@ -21,6 +21,10 @@ type resultTool struct { | |||||
| 21 | panic bool | 21 | panic bool | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | + | type immutableResultTool struct{ resultTool } | |||
| 25 | + | ||||
| 26 | + | func (*immutableResultTool) AllowArgumentRewrite() bool { return false } | |||
| 27 | + | ||||
| 24 | func (t *resultTool) Name() string { return "result" } | 28 | func (t *resultTool) Name() string { return "result" } | ||
| 25 | func (t *resultTool) Description() string { return "result" } | 29 | func (t *resultTool) Description() string { return "result" } | ||
| 26 | func (t *resultTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } | 30 | func (t *resultTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } | ||
| @@ -80,6 +84,37 @@ func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) { | |||||
| 80 | } | 84 | } | ||
| 81 | } | 85 | } | ||
| 82 | 86 | ||||
| 87 | + | func TestExecuteToolsEmitsPlanUpdateBeforeResult(t *testing.T) { | |||
| 88 | + | update := PlanUpdate{Plan: []PlanStep{{Step: "Implement", Status: PlanInProgress}}} | |||
| 89 | + | agent := NewAgent(nil, "test", "", Registry{ | |||
| 90 | + | "result": &resultTool{result: ToolResult{ | |||
| 91 | + | Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}, | |||
| 92 | + | Details: update, | |||
| 93 | + | }}, | |||
| 94 | + | }) | |||
| 95 | + | message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 96 | + | provider.ToolCallBlock{ID: "call-1", Name: "result", Arguments: json.RawMessage(`{}`)}, | |||
| 97 | + | }} | |||
| 98 | + | var events []AgentEvent | |||
| 99 | + | agent.executeTools(context.Background(), message, func(event AgentEvent) { | |||
| 100 | + | switch event.(type) { | |||
| 101 | + | case EvPlanUpdate, EvToolResult: | |||
| 102 | + | events = append(events, event) | |||
| 103 | + | } | |||
| 104 | + | }) | |||
| 105 | + | ||||
| 106 | + | if len(events) != 2 { | |||
| 107 | + | t.Fatalf("events = %#v, want plan update and tool result", events) | |||
| 108 | + | } | |||
| 109 | + | planEvent, ok := events[0].(EvPlanUpdate) | |||
| 110 | + | if !ok || len(planEvent.Update.Plan) != 1 || planEvent.Update.Plan[0].Step != "Implement" { | |||
| 111 | + | t.Fatalf("first event = %#v, want plan update", events[0]) | |||
| 112 | + | } | |||
| 113 | + | if _, ok := events[1].(EvToolResult); !ok { | |||
| 114 | + | t.Fatalf("second event = %T, want EvToolResult", events[1]) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 83 | func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) { | 118 | func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) { | ||
| 84 | agent := NewAgent(nil, "test", "", Registry{ | 119 | agent := NewAgent(nil, "test", "", Registry{ | ||
| 85 | "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}}, | 120 | "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}}, | ||
| @@ -98,9 +133,26 @@ func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) { | |||||
| 98 | } | 133 | } | ||
| 99 | } | 134 | } | ||
| 100 | 135 | ||||
| 101 | - | // TestBeforeToolExecuteModifiesArgs verifies that a non-nil | 136 | + | func TestBeforeToolExecuteRejectsRewriteForImmutableTool(t *testing.T) { |
| 102 | - | // modifiedArgs returned from BeforeToolExecute is what the tool | 137 | + | tool := &immutableResultTool{} |
| 103 | - | // actually sees. | 138 | + | agent := NewAgent(nil, "test", "", Registry{"result": tool}) |
| 139 | + | agent.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { | |||
| 140 | + | return true, "", json.RawMessage(`{"rewritten":true}`) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | result := agent.runOneTool(context.Background(), provider.ToolCallBlock{ | |||
| 144 | + | ID: "T1", Name: "result", Arguments: json.RawMessage(`{"original":true}`), | |||
| 145 | + | }, agent.ToolsSnapshot(), func(AgentEvent) {}) | |||
| 146 | + | if !result.IsError { | |||
| 147 | + | t.Fatalf("result = %#v, want rewrite error", result) | |||
| 148 | + | } | |||
| 149 | + | if got := result.Content[0].(provider.TextBlock).Text; got != "tool arguments cannot be rewritten" { | |||
| 150 | + | t.Fatalf("rewrite error = %q", got) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // TestBeforeToolExecuteModifiesArgs verifies that a non-nil modifiedArgs | |||
| 155 | + | // returned from BeforeToolExecute is what the tool actually sees. | |||
| 104 | func TestBeforeToolExecuteModifiesArgs(t *testing.T) { | 156 | func TestBeforeToolExecuteModifiesArgs(t *testing.T) { | ||
| 105 | rec := &recordingTool{} | 157 | rec := &recordingTool{} | ||
| 106 | reg := Registry{"echo": rec} | 158 | reg := Registry{"echo": rec} | ||
M packages/core/tool.go +6 -0
| @@ -32,6 +32,12 @@ type ToolPreviewer interface { | |||||
| 32 | Preview(ctx context.Context, args json.RawMessage) (ToolResult, error) | 32 | Preview(ctx context.Context, args json.RawMessage) (ToolResult, error) | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | + | // ToolArgumentRewritePolicy optionally prevents extension guards from | |||
| 36 | + | // replacing a tool call's arguments before execution. | |||
| 37 | + | type ToolArgumentRewritePolicy interface { | |||
| 38 | + | AllowArgumentRewrite() bool | |||
| 39 | + | } | |||
| 40 | + | ||||
| 35 | // ToolResult is the outcome of Tool.Execute. | 41 | // ToolResult is the outcome of Tool.Execute. | ||
| 36 | type ToolResult struct { | 42 | type ToolResult struct { | ||
| 37 | // Content is sent back to the LLM (text and/or images). | 43 | // Content is sent back to the LLM (text and/or images). | ||
M packages/tui/input.go +0 -5
| @@ -41,7 +41,6 @@ const ( | |||||
| 41 | KeyCtrlU | 41 | KeyCtrlU | ||
| 42 | KeyCtrlK | 42 | KeyCtrlK | ||
| 43 | KeyCtrlA | 43 | KeyCtrlA | ||
| 44 | - | KeyCtrlB | |||
| 45 | KeyCtrlE | 44 | KeyCtrlE | ||
| 46 | KeyCtrlW | 45 | KeyCtrlW | ||
| 47 | KeyCtrlO | 46 | KeyCtrlO | ||
| @@ -180,8 +179,6 @@ func (r *Reader) Read() (Key, error) { | |||||
| 180 | return Key{Kind: KeyCtrlK}, nil | 179 | return Key{Kind: KeyCtrlK}, nil | ||
| 181 | case b == 0x01: | 180 | case b == 0x01: | ||
| 182 | return Key{Kind: KeyCtrlA}, nil | 181 | return Key{Kind: KeyCtrlA}, nil | ||
| 183 | - | case b == 0x02: | |||
| 184 | - | return Key{Kind: KeyCtrlB}, nil | |||
| 185 | case b == 0x05: | 182 | case b == 0x05: | ||
| 186 | return Key{Kind: KeyCtrlE}, nil | 183 | return Key{Kind: KeyCtrlE}, nil | ||
| 187 | case b == 0x17: | 184 | case b == 0x17: | ||
| @@ -491,8 +488,6 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 491 | return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true | 488 | return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 492 | case 'a', 'A': | 489 | case 'a', 'A': | ||
| 493 | return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true | 490 | return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 494 | - | case 'b', 'B', 2: | |||
| 495 | - | return Key{Kind: KeyCtrlB, Shift: shift, Alt: alt, Ctrl: true}, true | |||
| 496 | case 'e', 'E': | 491 | case 'e', 'E': | ||
| 497 | return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true | 492 | return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 498 | case 'w', 'W': | 493 | case 'w', 'W': | ||
M packages/tui/input_test.go +0 -12
| @@ -62,18 +62,6 @@ func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |||||
| 62 | } | 62 | } | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | - | func TestReaderParsesCtrlB(t *testing.T) { | |||
| 66 | - | for _, input := range []string{"\x02", "\x1b[98;5u", "\x1b[27;5;98~"} { | |||
| 67 | - | k := readKey(t, input) | |||
| 68 | - | if k.Kind != KeyCtrlB { | |||
| 69 | - | t.Fatalf("Read(%q) kind=%v ctrl=%v, want ctrl+b", input, k.Kind, k.Ctrl) | |||
| 70 | - | } | |||
| 71 | - | if input != "\x02" && !k.Ctrl { | |||
| 72 | - | t.Fatalf("Read(%q) ctrl=%v, want enhanced ctrl+b metadata", input, k.Ctrl) | |||
| 73 | - | } | |||
| 74 | - | } | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | 65 | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | ||
| 78 | k := readKey(t, "\x1b[118;5u") | 66 | k := readKey(t, "\x1b[118;5u") | ||
| 79 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | 67 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | ||
M packages/tui/render.go +24 -48
| @@ -46,24 +46,15 @@ type Renderer struct { | |||||
| 46 | logLines []string | 46 | logLines []string | ||
| 47 | // Raw logical rows let DrawLog reject an unchanged frame before | 47 | // Raw logical rows let DrawLog reject an unchanged frame before | ||
| 48 | // truncating and repainting every transcript row. | 48 | // truncating and repainting every transcript row. | ||
| 49 | - | logRawChat []string | 49 | + | logRawChat []string |
| 50 | - | logRawBottom []string | 50 | + | logRawBottom []string |
| 51 | - | logRawCols int | 51 | + | logRawCols int |
| 52 | - | logRawRows int | 52 | + | logRawRows int |
| 53 | - | logRawContentCols int | 53 | + | logRawBackground string |
| 54 | - | logRawBackground string | 54 | + | logRawHasThemeBG bool |
| 55 | - | logRawHasThemeBG bool | 55 | + | logViewportTop int |
| 56 | - | logViewportTop int | 56 | + | logHardwareRow int |
| 57 | - | logHardwareRow int | 57 | + | logInit bool |
| 58 | - | logInit bool | |||
| 59 | - | ||||
| 60 | - | // The right bar is a viewport overlay on top of the main-screen flow | |||
| 61 | - | // renderer. Keeping its cache separate prevents persistent chrome from | |||
| 62 | - | // replacing the transcript's logical buffer and native scrollback. | |||
| 63 | - | rightBarActive bool | |||
| 64 | - | rightBarLines []string | |||
| 65 | - | rightBarMainCols int | |||
| 66 | - | rightBarDimmed bool | |||
| 67 | 58 | ||||
| 68 | // keepScrollback is true when we must NOT emit \x1b[3J | 59 | // keepScrollback is true when we must NOT emit \x1b[3J | ||
| 69 | // (erase-in-display 3, "clear scrollback"). | 60 | // (erase-in-display 3, "clear scrollback"). | ||
| @@ -142,9 +133,6 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 142 | r.logViewportTop = 0 | 133 | r.logViewportTop = 0 | ||
| 143 | r.logHardwareRow = 0 | 134 | r.logHardwareRow = 0 | ||
| 144 | r.logInit = false | 135 | r.logInit = false | ||
| 145 | - | r.rightBarActive = false | |||
| 146 | - | r.rightBarLines = nil | |||
| 147 | - | r.rightBarMainCols = 0 | |||
| 148 | if r.out != nil { | 136 | if r.out != nil { | ||
| 149 | if r.keepScrollback { | 137 | if r.keepScrollback { | ||
| 150 | // A resize is a discrete user action, like Ctrl+L: the | 138 | // A resize is a discrete user action, like Ctrl+L: the | ||
| @@ -181,9 +169,6 @@ func (r *Renderer) Clear() { | |||||
| 181 | r.logViewportTop = 0 | 169 | r.logViewportTop = 0 | ||
| 182 | r.logHardwareRow = 0 | 170 | r.logHardwareRow = 0 | ||
| 183 | r.logInit = false | 171 | r.logInit = false | ||
| 184 | - | r.rightBarActive = false | |||
| 185 | - | r.rightBarLines = nil | |||
| 186 | - | r.rightBarMainCols = 0 | |||
| 187 | if r.keepScrollback { | 172 | if r.keepScrollback { | ||
| 188 | // On VS Code's xterm.js the transcript is taller than the | 173 | // On VS Code's xterm.js the transcript is taller than the | ||
| 189 | // viewport, so an in-place clear (home + erase-to-end) only | 174 | // viewport, so an in-place clear (home + erase-to-end) only | ||
| @@ -599,34 +584,27 @@ func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int | |||||
| 599 | // | 584 | // | ||
| 600 | // cursorBottomRow/cursorCol are offsets into bottom, not the full frame. | 585 | // cursorBottomRow/cursorCol are offsets into bottom, not the full frame. | ||
| 601 | func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) { | 586 | func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) { | ||
| 602 | - | if r.rightBarActive { | 587 | + | r.drawLog(chat, bottom, cursorBottomRow, cursorCol) |
| 603 | - | r.clearRightBarOverlay() | |||
| 604 | - | } | |||
| 605 | - | r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols) | |||
| 606 | } | 588 | } | ||
| 607 | 589 | ||||
| 608 | - | // drawLog is DrawLog's owning flow implementation. contentCols may reserve | 590 | + | // drawLog is DrawLog's owning flow implementation. |
| 609 | - | // terminal columns for a separately composited viewport overlay. | 591 | + | func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int) bool { |
| 610 | - | func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool { | |||
| 611 | if r.cols == 0 || r.rows == 0 { | 592 | if r.cols == 0 || r.rows == 0 { | ||
| 612 | return false | 593 | return false | ||
| 613 | } | 594 | } | ||
| 614 | - | if contentCols <= 0 || contentCols > r.cols { | |||
| 615 | - | contentCols = r.cols | |||
| 616 | - | } | |||
| 617 | if len(bottom) == 0 { | 595 | if len(bottom) == 0 { | ||
| 618 | bottom = []string{""} | 596 | bottom = []string{""} | ||
| 619 | } | 597 | } | ||
| 620 | - | if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) { | 598 | + | if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol) { |
| 621 | return false | 599 | return false | ||
| 622 | } | 600 | } | ||
| 623 | chatFrame := make([]string, len(chat)) | 601 | chatFrame := make([]string, len(chat)) | ||
| 624 | for i, line := range chat { | 602 | for i, line := range chat { | ||
| 625 | - | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle) | 603 | + | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) |
| 626 | } | 604 | } | ||
| 627 | bottomFrame := make([]string, len(bottom)) | 605 | bottomFrame := make([]string, len(bottom)) | ||
| 628 | for i, line := range bottom { | 606 | for i, line := range bottom { | ||
| 629 | - | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle) | 607 | + | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) |
| 630 | } | 608 | } | ||
| 631 | 609 | ||||
| 632 | // Always reserve one real row below the editor/status band. This is | 610 | // Always reserve one real row below the editor/status band. This is | ||
| @@ -638,7 +616,7 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co | |||||
| 638 | lines = append(lines, chatFrame...) | 616 | lines = append(lines, chatFrame...) | ||
| 639 | lines = append(lines, bottomFrame...) | 617 | lines = append(lines, bottomFrame...) | ||
| 640 | for range bottomMarginRows { | 618 | for range bottomMarginRows { | ||
| 641 | - | lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle)) | 619 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) |
| 642 | } | 620 | } | ||
| 643 | // In main-screen flow mode zut normally emits only its logical | 621 | // In main-screen flow mode zut normally emits only its logical | ||
| 644 | // content rows and leaves the rest of the terminal viewport alone. | 622 | // content rows and leaves the rest of the terminal viewport alone. | ||
| @@ -648,7 +626,7 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co | |||||
| 648 | // default transparent case. | 626 | // default transparent case. | ||
| 649 | if r.theme.Background != nil { | 627 | if r.theme.Background != nil { | ||
| 650 | for len(lines) < r.rows { | 628 | for len(lines) < r.rows { | ||
| 651 | - | lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle)) | 629 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) |
| 652 | } | 630 | } | ||
| 653 | } | 631 | } | ||
| 654 | if len(lines) == 0 { | 632 | if len(lines) == 0 { | ||
| @@ -670,8 +648,7 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co | |||||
| 670 | // that never blinks, because we keep "showing" it before the | 648 | // that never blinks, because we keep "showing" it before the | ||
| 671 | // terminal can blink it off. Bailing out here lets the OS run | 649 | // terminal can blink it off. Bailing out here lets the OS run | ||
| 672 | // its blink cycle. | 650 | // its blink cycle. | ||
| 673 | - | if r.logInit && r.logRawContentCols == contentCols && | 651 | + | if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { |
| 674 | - | cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) { | |||
| 675 | return false | 652 | return false | ||
| 676 | } | 653 | } | ||
| 677 | 654 | ||||
| @@ -770,8 +747,8 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co | |||||
| 770 | if physicalCursorCol < 0 { | 747 | if physicalCursorCol < 0 { | ||
| 771 | physicalCursorCol = 0 | 748 | physicalCursorCol = 0 | ||
| 772 | } | 749 | } | ||
| 773 | - | if physicalCursorCol >= contentCols { | 750 | + | if physicalCursorCol >= r.cols { |
| 774 | - | physicalCursorCol = contentCols - 1 | 751 | + | physicalCursorCol = r.cols - 1 |
| 775 | } | 752 | } | ||
| 776 | if physicalCursorCol > 0 { | 753 | if physicalCursorCol > 0 { | ||
| 777 | w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C") | 754 | w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C") | ||
| @@ -1052,7 +1029,6 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co | |||||
| 1052 | r.logRawBottom = append(r.logRawBottom[:0], bottom...) | 1029 | r.logRawBottom = append(r.logRawBottom[:0], bottom...) | ||
| 1053 | r.logRawCols = r.cols | 1030 | r.logRawCols = r.cols | ||
| 1054 | r.logRawRows = r.rows | 1031 | r.logRawRows = r.rows | ||
| 1055 | - | r.logRawContentCols = contentCols | |||
| 1056 | r.logRawBackground = r.backgroundStyle | 1032 | r.logRawBackground = r.backgroundStyle | ||
| 1057 | r.logRawHasThemeBG = r.theme.Background != nil | 1033 | r.logRawHasThemeBG = r.theme.Background != nil | ||
| 1058 | r.cursorRow = cursorBottomRow | 1034 | r.cursorRow = cursorBottomRow | ||
| @@ -1060,14 +1036,14 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co | |||||
| 1060 | return true | 1036 | return true | ||
| 1061 | } | 1037 | } | ||
| 1062 | 1038 | ||||
| 1063 | - | func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool { | 1039 | + | func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol int) bool { |
| 1064 | return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && | 1040 | return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && | ||
| 1065 | - | r.logRawContentUnchanged(chat, bottom, contentCols) | 1041 | + | r.logRawContentUnchanged(chat, bottom) |
| 1066 | } | 1042 | } | ||
| 1067 | 1043 | ||||
| 1068 | - | func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool { | 1044 | + | func (r *Renderer) logRawContentUnchanged(chat, bottom []string) bool { |
| 1069 | return r.logInit && len(r.logLines) > 0 && | 1045 | return r.logInit && len(r.logLines) > 0 && | ||
| 1070 | - | r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols && | 1046 | + | r.logRawCols == r.cols && r.logRawRows == r.rows && |
| 1071 | r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) && | 1047 | r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) && | ||
| 1072 | sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) | 1048 | sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) | ||
| 1073 | } | 1049 | } | ||
D packages/tui/rightbar.go +0 -493
| @@ -1,493 +0,0 @@ | |||||
| 1 | - | package tui | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "io" | |||
| 5 | - | "sort" | |||
| 6 | - | "strings" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | const ( | |||
| 10 | - | // RightBarSeparatorWidth is the one-cell divider between the main pane | |||
| 11 | - | // and the host-owned right bar. | |||
| 12 | - | RightBarSeparatorWidth = 1 | |||
| 13 | - | // RightBarMinWidth keeps widget titles and checklist markers readable. | |||
| 14 | - | RightBarMinWidth = 24 | |||
| 15 | - | // RightBarMaxWidth prevents a long extension line from taking most of | |||
| 16 | - | // the transcript on wide terminals. | |||
| 17 | - | RightBarMaxWidth = 36 | |||
| 18 | - | // RightBarMinMainWidth preserves enough room for the editor and chat. | |||
| 19 | - | RightBarMinMainWidth = 48 | |||
| 20 | - | ||||
| 21 | - | // rightBarRightPadding leaves a small gutter between widget text and the | |||
| 22 | - | // terminal's right edge. | |||
| 23 | - | rightBarRightPadding = 1 | |||
| 24 | - | ) | |||
| 25 | - | ||||
| 26 | - | // RightBarWidget is declarative content for one persistent extension widget. | |||
| 27 | - | // The host owns ordering and layout; widgets are display-only in the first | |||
| 28 | - | // version and should use OpenPanel for interaction. | |||
| 29 | - | type RightBarWidget struct { | |||
| 30 | - | Extension string | |||
| 31 | - | ID string | |||
| 32 | - | Title string | |||
| 33 | - | Lines []string | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // RightBarColumns returns the main-pane and side-rail widths for a terminal. | |||
| 37 | - | // It returns ok=false when keeping both panes usable would leave too little | |||
| 38 | - | // room for the rail; callers should then render right-bar widgets using their | |||
| 39 | - | // normal fallback placement. | |||
| 40 | - | func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) { | |||
| 41 | - | if cols <= 0 { | |||
| 42 | - | return cols, 0, false | |||
| 43 | - | } | |||
| 44 | - | available := cols - RightBarSeparatorWidth | |||
| 45 | - | if available <= RightBarMinMainWidth { | |||
| 46 | - | return cols, 0, false | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | rightBarWidth = cols / 3 | |||
| 50 | - | if rightBarWidth < RightBarMinWidth { | |||
| 51 | - | rightBarWidth = RightBarMinWidth | |||
| 52 | - | } | |||
| 53 | - | if rightBarWidth > RightBarMaxWidth { | |||
| 54 | - | rightBarWidth = RightBarMaxWidth | |||
| 55 | - | } | |||
| 56 | - | if available-rightBarWidth < RightBarMinMainWidth { | |||
| 57 | - | rightBarWidth = available - RightBarMinMainWidth | |||
| 58 | - | } | |||
| 59 | - | if rightBarWidth < RightBarMinWidth { | |||
| 60 | - | return cols, 0, false | |||
| 61 | - | } | |||
| 62 | - | return available - rightBarWidth, rightBarWidth, true | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | // RenderRightBar renders a bounded, full-height side rail without adding a | |||
| 66 | - | // second frame around the host-owned separator. Widgets are sorted by | |||
| 67 | - | // extension name and ID so output stays deterministic even when extension | |||
| 68 | - | // frames arrive concurrently. Long lines are clipped with a three-dot ellipsis | |||
| 69 | - | // while reserving one cell of right padding. | |||
| 70 | - | func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string { | |||
| 71 | - | if width <= 0 || height <= 0 { | |||
| 72 | - | return nil | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | contentWidth := width - rightBarRightPadding | |||
| 76 | - | ordered := append([]RightBarWidget(nil), widgets...) | |||
| 77 | - | sort.SliceStable(ordered, func(a, b int) bool { | |||
| 78 | - | if ordered[a].Extension != ordered[b].Extension { | |||
| 79 | - | return ordered[a].Extension < ordered[b].Extension | |||
| 80 | - | } | |||
| 81 | - | return ordered[a].ID < ordered[b].ID | |||
| 82 | - | }) | |||
| 83 | - | ||||
| 84 | - | content := make([]string, 0, height) | |||
| 85 | - | truncated := false | |||
| 86 | - | padLine := func(text string, phase bool) string { | |||
| 87 | - | if contentWidth <= 0 { | |||
| 88 | - | return strings.Repeat(" ", width) | |||
| 89 | - | } | |||
| 90 | - | if phase { | |||
| 91 | - | text = truncateRightBarPhaseLine(text, contentWidth) | |||
| 92 | - | } else { | |||
| 93 | - | text = truncateRightBarLine(text, contentWidth) | |||
| 94 | - | } | |||
| 95 | - | if visible := visibleWidth(text); visible < width { | |||
| 96 | - | text += strings.Repeat(" ", width-visible) | |||
| 97 | - | } | |||
| 98 | - | return text | |||
| 99 | - | } | |||
| 100 | - | paintLine := func(text string, color *TerminalColor, dim, phase bool) string { | |||
| 101 | - | text = padLine(text, phase) | |||
| 102 | - | if color == nil { | |||
| 103 | - | return text | |||
| 104 | - | } | |||
| 105 | - | paint := func(value string, valueColor TerminalColor) string { | |||
| 106 | - | if dim { | |||
| 107 | - | value = Dim(value) | |||
| 108 | - | } | |||
| 109 | - | return th.FGColor(valueColor, value) | |||
| 110 | - | } | |||
| 111 | - | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | |||
| 112 | - | if phase { | |||
| 113 | - | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | |||
| 114 | - | return paint(text[:markerStart], *color) + | |||
| 115 | - | paint(text[markerStart:markerEnd], th.Muted) + | |||
| 116 | - | paint(text[markerEnd:nameStart], *color) + | |||
| 117 | - | paint(Bold(text[nameStart:nameEnd]), *color) + | |||
| 118 | - | paint(text[nameEnd:], *color) | |||
| 119 | - | } | |||
| 120 | - | } | |||
| 121 | - | return paint(text[:markerStart], *color) + | |||
| 122 | - | paint(text[markerStart:markerEnd], th.Muted) + | |||
| 123 | - | paint(text[markerEnd:], *color) | |||
| 124 | - | } | |||
| 125 | - | if phase { | |||
| 126 | - | return paint(Bold(text), *color) | |||
| 127 | - | } | |||
| 128 | - | return paint(text, *color) | |||
| 129 | - | } | |||
| 130 | - | appendContent := func(text string, color *TerminalColor, dim, phase bool) bool { | |||
| 131 | - | text = strings.ReplaceAll(text, "\r", "") | |||
| 132 | - | for _, line := range strings.Split(text, "\n") { | |||
| 133 | - | if len(content) >= height { | |||
| 134 | - | truncated = true | |||
| 135 | - | return false | |||
| 136 | - | } | |||
| 137 | - | content = append(content, paintLine(line, color, dim, phase)) | |||
| 138 | - | } | |||
| 139 | - | return true | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | for idx, widget := range ordered { | |||
| 143 | - | if idx > 0 && !appendContent("", nil, false, false) { | |||
| 144 | - | break | |||
| 145 | - | } | |||
| 146 | - | label := "[" + widget.Extension + "]" | |||
| 147 | - | if widget.Title != "" { | |||
| 148 | - | label += " " + widget.Title | |||
| 149 | - | } | |||
| 150 | - | if !appendContent(label, &th.Accent, false, false) { | |||
| 151 | - | break | |||
| 152 | - | } | |||
| 153 | - | if !appendContent("", nil, false, false) { | |||
| 154 | - | break | |||
| 155 | - | } | |||
| 156 | - | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | |||
| 157 | - | if activePhaseIndent >= 0 { | |||
| 158 | - | activePhaseIndent++ // host-owned line prefix | |||
| 159 | - | } | |||
| 160 | - | phaseIndent := rightBarChecklistIndent(widget.Lines) | |||
| 161 | - | if phaseIndent >= 0 { | |||
| 162 | - | phaseIndent++ // host-owned line prefix | |||
| 163 | - | } | |||
| 164 | - | activePhase := false | |||
| 165 | - | for _, line := range widget.Lines { | |||
| 166 | - | line = " " + line | |||
| 167 | - | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | |||
| 168 | - | phase := rightBarChecklistPhaseLine(line, phaseIndent) | |||
| 169 | - | if !appendContent(line, &th.FG, dim, phase) { | |||
| 170 | - | break | |||
| 171 | - | } | |||
| 172 | - | } | |||
| 173 | - | if truncated { | |||
| 174 | - | break | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | if truncated { | |||
| 178 | - | marker := paintLine("... right bar ...", &th.Muted, false, false) | |||
| 179 | - | if len(content) == height { | |||
| 180 | - | content[len(content)-1] = marker | |||
| 181 | - | } else { | |||
| 182 | - | content = append(content, marker) | |||
| 183 | - | } | |||
| 184 | - | } | |||
| 185 | - | for len(content) < height { | |||
| 186 | - | content = append(content, strings.Repeat(" ", width)) | |||
| 187 | - | } | |||
| 188 | - | return content | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) { | |||
| 192 | - | start = 0 | |||
| 193 | - | for start < len(text) && text[start] == ' ' { | |||
| 194 | - | start++ | |||
| 195 | - | } | |||
| 196 | - | if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' { | |||
| 197 | - | return 0, 0, 0, false | |||
| 198 | - | } | |||
| 199 | - | switch text[start+1] { | |||
| 200 | - | case ' ', 'x', 'X', '>': | |||
| 201 | - | return start, start + 3, text[start+1], true | |||
| 202 | - | default: | |||
| 203 | - | return 0, 0, 0, false | |||
| 204 | - | } | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | func truncateRightBarLine(text string, width int) string { | |||
| 208 | - | if width <= 0 || visibleWidth(text) <= width { | |||
| 209 | - | return text | |||
| 210 | - | } | |||
| 211 | - | if width <= 3 { | |||
| 212 | - | return truncateToWidth("...", width) | |||
| 213 | - | } | |||
| 214 | - | return truncateToWidth(text, width-3) + "..." | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | func truncateRightBarPhaseLine(text string, width int) string { | |||
| 218 | - | if width <= 0 || visibleWidth(text) <= width { | |||
| 219 | - | return text | |||
| 220 | - | } | |||
| 221 | - | nameStart, nameEnd, ok := rightBarChecklistPhaseName(text) | |||
| 222 | - | if !ok { | |||
| 223 | - | return truncateRightBarLine(text, width) | |||
| 224 | - | } | |||
| 225 | - | prefix := text[:nameStart] | |||
| 226 | - | suffix := text[nameEnd:] | |||
| 227 | - | nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix) | |||
| 228 | - | if nameWidth == 0 { | |||
| 229 | - | return prefix + suffix | |||
| 230 | - | } | |||
| 231 | - | if nameWidth < 0 { | |||
| 232 | - | return truncateRightBarLine(text, width) | |||
| 233 | - | } | |||
| 234 | - | return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | func rightBarChecklistIndent(lines []string) int { | |||
| 238 | - | indent := -1 | |||
| 239 | - | for _, line := range lines { | |||
| 240 | - | start, _, _, ok := rightBarChecklistMarker(line) | |||
| 241 | - | if ok && (indent < 0 || start < indent) { | |||
| 242 | - | indent = start | |||
| 243 | - | } | |||
| 244 | - | } | |||
| 245 | - | return indent | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | func rightBarChecklistPhaseLine(text string, phaseIndent int) bool { | |||
| 249 | - | if phaseIndent < 0 { | |||
| 250 | - | return false | |||
| 251 | - | } | |||
| 252 | - | start, _, _, ok := rightBarChecklistMarker(text) | |||
| 253 | - | return ok && start == phaseIndent | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | func rightBarChecklistPhaseName(text string) (start, end int, ok bool) { | |||
| 257 | - | _, markerEnd, _, markerOK := rightBarChecklistMarker(text) | |||
| 258 | - | if !markerOK { | |||
| 259 | - | return 0, 0, false | |||
| 260 | - | } | |||
| 261 | - | start = markerEnd | |||
| 262 | - | if start < len(text) && text[start] == ' ' { | |||
| 263 | - | start++ | |||
| 264 | - | } | |||
| 265 | - | trimmedEnd := len(strings.TrimRight(text, " ")) | |||
| 266 | - | if trimmedEnd <= start { | |||
| 267 | - | return 0, 0, false | |||
| 268 | - | } | |||
| 269 | - | suffix := text[start:trimmedEnd] | |||
| 270 | - | separator := strings.LastIndex(suffix, " ") | |||
| 271 | - | if separator < 0 { | |||
| 272 | - | return rightBarFallbackPhaseName(text, start, trimmedEnd) | |||
| 273 | - | } | |||
| 274 | - | nameEnd := start + separator | |||
| 275 | - | progress := strings.TrimSpace(text[nameEnd:trimmedEnd]) | |||
| 276 | - | if !rightBarProgressToken(progress) { | |||
| 277 | - | return rightBarFallbackPhaseName(text, start, trimmedEnd) | |||
| 278 | - | } | |||
| 279 | - | for nameEnd > start && text[nameEnd-1] == ' ' { | |||
| 280 | - | nameEnd-- | |||
| 281 | - | } | |||
| 282 | - | if nameEnd <= start { | |||
| 283 | - | return 0, 0, false | |||
| 284 | - | } | |||
| 285 | - | return start, nameEnd, true | |||
| 286 | - | } | |||
| 287 | - | ||||
| 288 | - | func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) { | |||
| 289 | - | for end > start && text[end-1] == ' ' { | |||
| 290 | - | end-- | |||
| 291 | - | } | |||
| 292 | - | if end-start >= 3 && text[end-3:end] == "..." { | |||
| 293 | - | end -= 3 | |||
| 294 | - | } | |||
| 295 | - | for end > start && text[end-1] == ' ' { | |||
| 296 | - | end-- | |||
| 297 | - | } | |||
| 298 | - | if end <= start { | |||
| 299 | - | return 0, 0, false | |||
| 300 | - | } | |||
| 301 | - | return start, end, true | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | func rightBarProgressToken(value string) bool { | |||
| 305 | - | separator := strings.IndexByte(value, '/') | |||
| 306 | - | if separator <= 0 || separator == len(value)-1 { | |||
| 307 | - | return false | |||
| 308 | - | } | |||
| 309 | - | for _, part := range []string{value[:separator], value[separator+1:]} { | |||
| 310 | - | for _, r := range part { | |||
| 311 | - | if r < '0' || r > '9' { | |||
| 312 | - | return false | |||
| 313 | - | } | |||
| 314 | - | } | |||
| 315 | - | } | |||
| 316 | - | return true | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | func rightBarActivePhaseIndent(lines []string) int { | |||
| 320 | - | for _, line := range lines { | |||
| 321 | - | start, _, marker, ok := rightBarChecklistMarker(line) | |||
| 322 | - | if ok && marker == '>' { | |||
| 323 | - | return start | |||
| 324 | - | } | |||
| 325 | - | } | |||
| 326 | - | return -1 | |||
| 327 | - | } | |||
| 328 | - | ||||
| 329 | - | func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool { | |||
| 330 | - | if activePhaseIndent < 0 { | |||
| 331 | - | return false | |||
| 332 | - | } | |||
| 333 | - | start, _, marker, ok := rightBarChecklistMarker(text) | |||
| 334 | - | if !ok { | |||
| 335 | - | return false | |||
| 336 | - | } | |||
| 337 | - | if start == activePhaseIndent { | |||
| 338 | - | *activePhase = marker == '>' | |||
| 339 | - | return !*activePhase | |||
| 340 | - | } | |||
| 341 | - | if start > activePhaseIndent { | |||
| 342 | - | return !*activePhase | |||
| 343 | - | } | |||
| 344 | - | return false | |||
| 345 | - | } | |||
| 346 | - | ||||
| 347 | - | // JoinRightBar combines one main-pane row and one right-bar row without | |||
| 348 | - | // allowing either side to soft-wrap. The returned row occupies exactly | |||
| 349 | - | // mainWidth + RightBarSeparatorWidth + rightBarWidth display cells. | |||
| 350 | - | func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string { | |||
| 351 | - | return joinRightBar(th, main, rightBar, mainWidth, rightBarWidth, false) | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int, dimSeparator bool) string { | |||
| 355 | - | if mainWidth < 1 { | |||
| 356 | - | mainWidth = 1 | |||
| 357 | - | } | |||
| 358 | - | if rightBarWidth < 1 { | |||
| 359 | - | rightBarWidth = 1 | |||
| 360 | - | } | |||
| 361 | - | main = truncateToWidth(main, mainWidth) | |||
| 362 | - | if visible := visibleWidth(main); visible < mainWidth { | |||
| 363 | - | main += strings.Repeat(" ", mainWidth-visible) | |||
| 364 | - | } | |||
| 365 | - | rightBar = truncateToWidth(rightBar, rightBarWidth) | |||
| 366 | - | if visible := visibleWidth(rightBar); visible < rightBarWidth { | |||
| 367 | - | rightBar += strings.Repeat(" ", rightBarWidth-visible) | |||
| 368 | - | } | |||
| 369 | - | separator := th.FGColor(th.Muted, "│") | |||
| 370 | - | if dimSeparator { | |||
| 371 | - | separator = Dim(separator) | |||
| 372 | - | } | |||
| 373 | - | return main + separator + rightBar | |||
| 374 | - | } | |||
| 375 | - | ||||
| 376 | - | // DrawRightBar renders the complete transcript through DrawLog, then | |||
| 377 | - | // composites the persistent rail over the visible viewport. The flow cache | |||
| 378 | - | // remains authoritative so appended chat still enters native scrollback. | |||
| 379 | - | func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | |||
| 380 | - | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false) | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | - | // DrawRightBarDimmed renders a right-bar frame whose separator is part of a | |||
| 384 | - | // dimmed backdrop. Callers must pass already-dimmed main and right-bar rows. | |||
| 385 | - | func (r *Renderer) DrawRightBarDimmed(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) { | |||
| 386 | - | r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, true) | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int, dimSeparator bool) { | |||
| 390 | - | if r.cols == 0 || r.rows == 0 { | |||
| 391 | - | return | |||
| 392 | - | } | |||
| 393 | - | mainWidth, rightBarWidth, ok := RightBarColumns(r.cols) | |||
| 394 | - | if !ok { | |||
| 395 | - | r.DrawLog(chat, bottom, cursorBottomRow, cursorCol) | |||
| 396 | - | return | |||
| 397 | - | } | |||
| 398 | - | ||||
| 399 | - | if len(bottom) == 0 { | |||
| 400 | - | bottom = []string{""} | |||
| 401 | - | } | |||
| 402 | - | ||||
| 403 | - | // Keep DrawLog's final blank margin unobstructed, matching the normal flow | |||
| 404 | - | // renderer and the previous right-bar layout. | |||
| 405 | - | frameRows := max(0, r.rows-1) | |||
| 406 | - | barLines := make([]string, frameRows) | |||
| 407 | - | copy(barLines, rightBar) | |||
| 408 | - | ||||
| 409 | - | barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth || | |||
| 410 | - | r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines) | |||
| 411 | - | contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth) | |||
| 412 | - | flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol | |||
| 413 | - | if flowUnchanged && !barChanged { | |||
| 414 | - | return | |||
| 415 | - | } | |||
| 416 | - | if !contentUnchanged && r.rightBarActive { | |||
| 417 | - | // Remove viewport chrome before DrawLog can naturally scroll a row into | |||
| 418 | - | // history. Otherwise every appended transcript row captures a stale | |||
| 419 | - | // copy of the rail in native terminal scrollback. | |||
| 420 | - | r.clearRightBarOverlay() | |||
| 421 | - | } | |||
| 422 | - | r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth) | |||
| 423 | - | if contentUnchanged && !barChanged { | |||
| 424 | - | // Cursor-only updates do not disturb the composited rail. | |||
| 425 | - | return | |||
| 426 | - | } | |||
| 427 | - | r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator) | |||
| 428 | - | r.rightBarActive = true | |||
| 429 | - | r.rightBarLines = append(r.rightBarLines[:0], barLines...) | |||
| 430 | - | r.rightBarMainCols = mainWidth | |||
| 431 | - | r.rightBarDimmed = dimSeparator | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | // clearRightBarOverlay removes viewport chrome before DrawLog expands back to | |||
| 435 | - | // the terminal width. Restoring the tracked flow cursor keeps DrawLog's | |||
| 436 | - | // hardware-row model valid across the intermediate absolute writes. | |||
| 437 | - | func (r *Renderer) clearRightBarOverlay() { | |||
| 438 | - | if !r.rightBarActive { | |||
| 439 | - | return | |||
| 440 | - | } | |||
| 441 | - | remaining := r.cols - r.rightBarMainCols | |||
| 442 | - | if remaining > 0 { | |||
| 443 | - | blank := strings.Repeat(" ", remaining) | |||
| 444 | - | var w strings.Builder | |||
| 445 | - | w.WriteString(SeqSynchronizedOn) | |||
| 446 | - | w.WriteString(SeqHideCursor) | |||
| 447 | - | for row := 0; row < max(0, r.rows-1); row++ { | |||
| 448 | - | w.WriteString(MoveTo(row+1, r.rightBarMainCols+1)) | |||
| 449 | - | w.WriteString("\x1b[0m") | |||
| 450 | - | w.WriteString(blank) | |||
| 451 | - | } | |||
| 452 | - | r.appendTrackedLogCursor(&w, r.rightBarMainCols) | |||
| 453 | - | w.WriteString(SeqSynchronizedOff) | |||
| 454 | - | _, _ = io.WriteString(r.out, w.String()) | |||
| 455 | - | } | |||
| 456 | - | r.rightBarActive = false | |||
| 457 | - | r.rightBarLines = nil | |||
| 458 | - | r.rightBarMainCols = 0 | |||
| 459 | - | r.rightBarDimmed = false | |||
| 460 | - | } | |||
| 461 | - | ||||
| 462 | - | func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) { | |||
| 463 | - | var w strings.Builder | |||
| 464 | - | w.WriteString(SeqSynchronizedOn) | |||
| 465 | - | w.WriteString(SeqHideCursor) | |||
| 466 | - | for row, line := range rightBar { | |||
| 467 | - | w.WriteString(MoveTo(row+1, mainWidth+1)) | |||
| 468 | - | w.WriteString("\x1b[0m") | |||
| 469 | - | w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator)) | |||
| 470 | - | } | |||
| 471 | - | r.appendTrackedLogCursor(&w, mainWidth) | |||
| 472 | - | w.WriteString(SeqSynchronizedOff) | |||
| 473 | - | _, _ = io.WriteString(r.out, w.String()) | |||
| 474 | - | } | |||
| 475 | - | ||||
| 476 | - | func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) { | |||
| 477 | - | if !r.logInit || r.cursorRow < 0 { | |||
| 478 | - | return | |||
| 479 | - | } | |||
| 480 | - | row := len(r.logChat) + r.cursorRow - r.logViewportTop | |||
| 481 | - | if row < 0 || row >= r.rows { | |||
| 482 | - | return | |||
| 483 | - | } | |||
| 484 | - | cursorCol := r.cursorCol | |||
| 485 | - | if cursorCol < 0 { | |||
| 486 | - | cursorCol = 0 | |||
| 487 | - | } | |||
| 488 | - | if cursorCol >= contentCols { | |||
| 489 | - | cursorCol = contentCols - 1 | |||
| 490 | - | } | |||
| 491 | - | w.WriteString(MoveTo(row+1, cursorCol+1)) | |||
| 492 | - | w.WriteString(SeqShowCursor) | |||
| 493 | - | } | |||
D packages/tui/rightbar_test.go +0 -452
| @@ -1,452 +0,0 @@ | |||||
| 1 | - | package tui | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "strings" | |||
| 6 | - | "testing" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestRightBarColumnsFallsBackWhenMainPaneWouldBeTooNarrow(t *testing.T) { | |||
| 10 | - | if _, _, ok := RightBarColumns(72); ok { | |||
| 11 | - | t.Fatal("right bar should fall back at 72 columns") | |||
| 12 | - | } | |||
| 13 | - | main, rail, ok := RightBarColumns(73) | |||
| 14 | - | if !ok { | |||
| 15 | - | t.Fatal("right bar should fit at 73 columns") | |||
| 16 | - | } | |||
| 17 | - | if main < RightBarMinMainWidth || rail < RightBarMinWidth || rail > RightBarMaxWidth { | |||
| 18 | - | t.Fatalf("right bar columns = main %d, rail %d", main, rail) | |||
| 19 | - | } | |||
| 20 | - | if main+RightBarSeparatorWidth+rail != 73 { | |||
| 21 | - | t.Fatalf("column budget = %d, want 73", main+RightBarSeparatorWidth+rail) | |||
| 22 | - | } | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) { | |||
| 26 | - | lines := RenderRightBar(Dark, []RightBarWidget{ | |||
| 27 | - | {Extension: "zeta", ID: "second", Title: "Z", Lines: []string{"z"}}, | |||
| 28 | - | {Extension: "alpha", ID: "first", Title: "A", Lines: []string{"a"}}, | |||
| 29 | - | }, 24, 7) | |||
| 30 | - | if len(lines) != 7 { | |||
| 31 | - | t.Fatalf("right bar rows = %d, want 7", len(lines)) | |||
| 32 | - | } | |||
| 33 | - | for idx, line := range lines { | |||
| 34 | - | if width := visibleWidth(line); width != 24 { | |||
| 35 | - | t.Fatalf("right bar line %d width = %d, want 24: %q", idx, width, line) | |||
| 36 | - | } | |||
| 37 | - | } | |||
| 38 | - | joined := stripANSI(strings.Join(lines, "\n")) | |||
| 39 | - | if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") { | |||
| 40 | - | t.Fatalf("widgets are not deterministic by extension: %q", joined) | |||
| 41 | - | } | |||
| 42 | - | if strings.ContainsAny(joined, "┌┐└┘─") { | |||
| 43 | - | t.Fatalf("right bar added an outer border: %q", joined) | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | truncated := RenderRightBar(Dark, []RightBarWidget{ | |||
| 47 | - | {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f", "g", "h"}}, | |||
| 48 | - | }, 24, 7) | |||
| 49 | - | if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") { | |||
| 50 | - | t.Fatalf("truncation marker missing: %q", truncated) | |||
| 51 | - | } | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) { | |||
| 55 | - | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 56 | - | Extension: "plan", | |||
| 57 | - | Lines: []string{"one two three four five six"}, | |||
| 58 | - | }}, 16, 8) | |||
| 59 | - | ||||
| 60 | - | if len(lines) != 8 { | |||
| 61 | - | t.Fatalf("right bar rows = %d, want 8", len(lines)) | |||
| 62 | - | } | |||
| 63 | - | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 64 | - | if !strings.Contains(plain, " one two thr...") || strings.Contains(plain, "four five six") { | |||
| 65 | - | t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain) | |||
| 66 | - | } | |||
| 67 | - | for idx, line := range lines { | |||
| 68 | - | if width := visibleWidth(line); width != 16 { | |||
| 69 | - | t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line) | |||
| 70 | - | } | |||
| 71 | - | } | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | func TestRenderRightBarLeavesOneCellOfRightPadding(t *testing.T) { | |||
| 75 | - | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 76 | - | Extension: "tasked-phases", | |||
| 77 | - | Title: "p 0/1 | t 0/1", | |||
| 78 | - | Lines: []string{"[>] A phase title that is much too long to fit 0/1"}, | |||
| 79 | - | }}, 24, 4) | |||
| 80 | - | ||||
| 81 | - | for idx, line := range lines { | |||
| 82 | - | plain := stripANSI(line) | |||
| 83 | - | if !strings.HasSuffix(plain, " ") { | |||
| 84 | - | t.Fatalf("right bar line %d lacks right padding: %q", idx, plain) | |||
| 85 | - | } | |||
| 86 | - | if visibleWidth(strings.TrimRight(plain, " ")) > 23 { | |||
| 87 | - | t.Fatalf("right bar line %d content reaches the right edge: %q", idx, plain) | |||
| 88 | - | } | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | |||
| 93 | - | lines := RenderRightBar(Dark, []RightBarWidget{ | |||
| 94 | - | { | |||
| 95 | - | Extension: "tasked-phases", | |||
| 96 | - | Lines: []string{"[ ] A phase title that is much too long to fit 0/3"}, | |||
| 97 | - | }, | |||
| 98 | - | }, 24, 5) | |||
| 99 | - | plainLines := strings.Split(stripANSI(strings.Join(lines, "\n")), "\n") | |||
| 100 | - | if len(plainLines) < 3 { | |||
| 101 | - | t.Fatalf("phase row is missing: %q", plainLines) | |||
| 102 | - | } | |||
| 103 | - | phase := strings.TrimRight(plainLines[2], " ") | |||
| 104 | - | if !strings.HasSuffix(phase, "0/3") || !strings.Contains(phase, "... 0/3") { | |||
| 105 | - | t.Fatalf("phase progress was clipped with its title: %q", phase) | |||
| 106 | - | } | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | func TestTruncateRightBarPhaseLinePreservesProgressWithoutRoomForName(t *testing.T) { | |||
| 110 | - | got := truncateRightBarPhaseLine("[>] Current phase 0/1", 9) | |||
| 111 | - | if want := "[>] 0/1"; got != want { | |||
| 112 | - | t.Fatalf("truncated phase = %q, want %q", got, want) | |||
| 113 | - | } | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | |||
| 117 | - | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 118 | - | Extension: "tasked-phases", | |||
| 119 | - | Title: "p 0/1 | t 0/1", | |||
| 120 | - | Lines: []string{ | |||
| 121 | - | "[>] Copper Lantern Setup 0/1", | |||
| 122 | - | " [ ] Sort the leftover star stickers", | |||
| 123 | - | }, | |||
| 124 | - | }}, 36, 8) | |||
| 125 | - | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 126 | - | if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") { | |||
| 127 | - | t.Fatalf("phase row has the wrong indentation: %q", plain) | |||
| 128 | - | } | |||
| 129 | - | plainLines := strings.Split(plain, "\n") | |||
| 130 | - | if len(plainLines) < 4 { | |||
| 131 | - | t.Fatalf("checklist rows are missing: %q", plain) | |||
| 132 | - | } | |||
| 133 | - | const expectedTask = " [ ] Sort the leftover star sti..." | |||
| 134 | - | if got := strings.TrimRight(plainLines[3], " "); got != expectedTask { | |||
| 135 | - | t.Fatalf("task row = %q, want clipped row %q", got, expectedTask) | |||
| 136 | - | } | |||
| 137 | - | if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) { | |||
| 138 | - | t.Fatalf("phase name is not bold: %q", lines) | |||
| 139 | - | } | |||
| 140 | - | if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) { | |||
| 141 | - | t.Fatalf("task text should not be bold: %q", lines) | |||
| 142 | - | } | |||
| 143 | - | if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" { | |||
| 144 | - | t.Fatalf("widget title is not followed by an empty row: %q", plain) | |||
| 145 | - | } | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | func TestRenderRightBarDimsChecklistMarkers(t *testing.T) { | |||
| 149 | - | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 150 | - | Extension: "plan", | |||
| 151 | - | Lines: []string{"[x] done", "[>] active", "[ ] pending"}, | |||
| 152 | - | }}, 24, 8) | |||
| 153 | - | rendered := strings.Join(lines, "\n") | |||
| 154 | - | for _, marker := range []string{"[x]", "[>]", "[ ]"} { | |||
| 155 | - | if !strings.Contains(rendered, Dark.FGColor(Dark.Muted, marker)) && | |||
| 156 | - | !strings.Contains(rendered, Dark.FGColor(Dark.Muted, Dim(marker))) { | |||
| 157 | - | t.Fatalf("marker %q was not muted: %q", marker, rendered) | |||
| 158 | - | } | |||
| 159 | - | } | |||
| 160 | - | if !strings.Contains(stripANSI(rendered), " done") { | |||
| 161 | - | t.Fatalf("checklist text was not kept readable: %q", rendered) | |||
| 162 | - | } | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | - | func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) { | |||
| 166 | - | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 167 | - | Extension: "plan", | |||
| 168 | - | Lines: []string{ | |||
| 169 | - | "[x] finished phase", | |||
| 170 | - | " [x] finished task", | |||
| 171 | - | "[>] active phase", | |||
| 172 | - | " [>] active task", | |||
| 173 | - | "[ ] pending phase", | |||
| 174 | - | " [ ] pending task", | |||
| 175 | - | }, | |||
| 176 | - | }}, 32, 12) | |||
| 177 | - | for _, line := range lines { | |||
| 178 | - | if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") { | |||
| 179 | - | if strings.Contains(line, "\x1b[2m") { | |||
| 180 | - | t.Fatalf("active checklist row was dimmed: %q", line) | |||
| 181 | - | } | |||
| 182 | - | } | |||
| 183 | - | } | |||
| 184 | - | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 185 | - | for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} { | |||
| 186 | - | if !strings.Contains(plain, text) { | |||
| 187 | - | t.Fatalf("inactive checklist text %q missing: %q", text, plain) | |||
| 188 | - | } | |||
| 189 | - | } | |||
| 190 | - | if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") { | |||
| 191 | - | t.Fatalf("inactive checklist rows were not dimmed: %q", lines) | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) { | |||
| 196 | - | line := JoinRightBar(Dark, "main", "right", 8, 8) | |||
| 197 | - | if !strings.Contains(line, Dark.FGColor(Dark.Muted, "│")) { | |||
| 198 | - | t.Fatalf("separator is not muted: %q", line) | |||
| 199 | - | } | |||
| 200 | - | if strings.ContainsAny(stripANSI(line), "┌┐└┘─") { | |||
| 201 | - | t.Fatalf("joined right bar added an outer rule: %q", line) | |||
| 202 | - | } | |||
| 203 | - | } | |||
| 204 | - | ||||
| 205 | - | func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |||
| 206 | - | line := JoinRightBar(Dark, "界", "✓ done", 8, 6) | |||
| 207 | - | if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want { | |||
| 208 | - | t.Fatalf("joined width = %d, want %d: %q", got, want, line) | |||
| 209 | - | } | |||
| 210 | - | } | |||
| 211 | - | ||||
| 212 | - | func TestRightBarTruncationPreservesOSCSequences(t *testing.T) { | |||
| 213 | - | for _, tc := range []struct { | |||
| 214 | - | opener string | |||
| 215 | - | closer string | |||
| 216 | - | }{ | |||
| 217 | - | {"\x1b]8;;https://example.test\x07", "\x1b]8;;\x07"}, | |||
| 218 | - | {"\x1b]8;;https://example.test\x1b\\", "\x1b]8;;\x1b\\"}, | |||
| 219 | - | } { | |||
| 220 | - | got := JoinRightBar(Dark, tc.opener+"label-more", "", 5, 1) | |||
| 221 | - | if !strings.HasPrefix(got, tc.opener+"label") { | |||
| 222 | - | t.Fatalf("truncated right-bar row split OSC escape %q: %q", tc.opener, got) | |||
| 223 | - | } | |||
| 224 | - | got = truncateToWidth("12345"+tc.opener+"tail", 5) | |||
| 225 | - | if got != "12345" { | |||
| 226 | - | t.Fatalf("truncated right-bar row leaked trailing OSC escape %q: %q", tc.opener, got) | |||
| 227 | - | } | |||
| 228 | - | got = truncateToWidth(tc.opener+"12345x"+tc.closer, 5) | |||
| 229 | - | if !strings.HasSuffix(got, "\x1b]8;;\x07") && !strings.HasSuffix(got, tc.closer) { | |||
| 230 | - | t.Fatalf("truncated right-bar row left OSC 8 hyperlink open: %q", got) | |||
| 231 | - | } | |||
| 232 | - | } | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | func TestRendererDrawRightBarKeepsFlowScrollback(t *testing.T) { | |||
| 236 | - | t.Setenv("TERM_PROGRAM", "") | |||
| 237 | - | ||||
| 238 | - | var out bytes.Buffer | |||
| 239 | - | r := NewRenderer(&out) | |||
| 240 | - | r.SetTheme(Dark) | |||
| 241 | - | r.Resize(80, 5) | |||
| 242 | - | chat := []string{"one", "two", "three", "four", "five", "six"} | |||
| 243 | - | r.DrawRightBar( | |||
| 244 | - | chat, | |||
| 245 | - | []string{"input"}, | |||
| 246 | - | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |||
| 247 | - | 0, | |||
| 248 | - | 0, | |||
| 249 | - | ) | |||
| 250 | - | if !r.logInit { | |||
| 251 | - | t.Fatal("right-bar draw abandoned the flow renderer") | |||
| 252 | - | } | |||
| 253 | - | if got := len(r.logChat); got != len(chat) { | |||
| 254 | - | t.Fatalf("cached chat rows = %d, want complete transcript length %d", got, len(chat)) | |||
| 255 | - | } | |||
| 256 | - | ||||
| 257 | - | out.Reset() | |||
| 258 | - | chat = append(chat, "seven") | |||
| 259 | - | r.DrawRightBar( | |||
| 260 | - | chat, | |||
| 261 | - | []string{"input"}, | |||
| 262 | - | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |||
| 263 | - | 0, | |||
| 264 | - | 0, | |||
| 265 | - | ) | |||
| 266 | - | got := out.String() | |||
| 267 | - | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |||
| 268 | - | t.Fatalf("right-bar append cleared retained scrollback: %q", got) | |||
| 269 | - | } | |||
| 270 | - | if !strings.Contains(stripANSI(got), "seven") { | |||
| 271 | - | t.Fatalf("right-bar append omitted new transcript row: %q", got) | |||
| 272 | - | } | |||
| 273 | - | mainWidth, _, _ := RightBarColumns(80) | |||
| 274 | - | clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth) | |||
| 275 | - | if clearAt, appendAt := strings.Index(got, clearedRail), strings.Index(got, "seven"); clearAt < 0 || clearAt > appendAt { | |||
| 276 | - | t.Fatalf("right bar was not removed before transcript append entered scrollback: %q", got) | |||
| 277 | - | } | |||
| 278 | - | if r.logViewportTop == 0 { | |||
| 279 | - | t.Fatal("test did not exercise transcript rows above the viewport") | |||
| 280 | - | } | |||
| 281 | - | } | |||
| 282 | - | ||||
| 283 | - | func TestRendererDrawRightBarIdleNoOpEmitsNothing(t *testing.T) { | |||
| 284 | - | t.Setenv("TERM_PROGRAM", "") | |||
| 285 | - | var out bytes.Buffer | |||
| 286 | - | r := NewRenderer(&out) | |||
| 287 | - | r.SetTheme(Dark) | |||
| 288 | - | r.Resize(80, 5) | |||
| 289 | - | chat := []string{"chat"} | |||
| 290 | - | bottom := []string{"input"} | |||
| 291 | - | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |||
| 292 | - | r.DrawRightBar(chat, bottom, rail, 0, 0) | |||
| 293 | - | out.Reset() | |||
| 294 | - | ||||
| 295 | - | r.DrawRightBar(chat, bottom, rail, 0, 0) | |||
| 296 | - | if out.Len() != 0 { | |||
| 297 | - | t.Fatalf("idle right-bar redraw emitted %d bytes: %q", out.Len(), out.String()) | |||
| 298 | - | } | |||
| 299 | - | } | |||
| 300 | - | ||||
| 301 | - | func TestRendererDrawRightBarCursorMoveDoesNotRepaintRail(t *testing.T) { | |||
| 302 | - | t.Setenv("TERM_PROGRAM", "") | |||
| 303 | - | var out bytes.Buffer | |||
| 304 | - | r := NewRenderer(&out) | |||
| 305 | - | r.SetTheme(Dark) | |||
| 306 | - | r.Resize(80, 5) | |||
| 307 | - | chat := []string{"chat"} | |||
| 308 | - | bottom := []string{"input"} | |||
| 309 | - | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |||
| 310 | - | r.DrawRightBar(chat, bottom, rail, 0, 0) | |||
| 311 | - | out.Reset() | |||
| 312 | - | ||||
| 313 | - | r.DrawRightBar(chat, bottom, rail, 0, 1) | |||
| 314 | - | if out.Len() == 0 { | |||
| 315 | - | t.Fatal("cursor-only right-bar update emitted nothing") | |||
| 316 | - | } | |||
| 317 | - | if !strings.Contains(out.String(), "\r\x1b[1C") { | |||
| 318 | - | t.Fatalf("cursor-only update did not move the cursor to column 1: %q", out.String()) | |||
| 319 | - | } | |||
| 320 | - | if strings.Contains(stripANSI(out.String()), "task") { | |||
| 321 | - | t.Fatalf("cursor-only update repainted the entire rail: %q", out.String()) | |||
| 322 | - | } | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | func TestRendererDrawRightBarClampsCursorToMainContent(t *testing.T) { | |||
| 326 | - | t.Setenv("TERM_PROGRAM", "") | |||
| 327 | - | var out bytes.Buffer | |||
| 328 | - | r := NewRenderer(&out) | |||
| 329 | - | r.SetTheme(Dark) | |||
| 330 | - | r.Resize(80, 5) | |||
| 331 | - | rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5) | |||
| 332 | - | r.DrawRightBar([]string{"chat"}, []string{"input"}, rail, 0, 100) | |||
| 333 | - | ||||
| 334 | - | mainWidth, _, ok := RightBarColumns(80) | |||
| 335 | - | if !ok { | |||
| 336 | - | t.Fatal("right bar should fit") | |||
| 337 | - | } | |||
| 338 | - | got := out.String() | |||
| 339 | - | if strings.Contains(got, MoveTo(2, 101)) { | |||
| 340 | - | t.Fatalf("cursor escaped the main content area: %q", got) | |||
| 341 | - | } | |||
| 342 | - | if !strings.Contains(got, MoveTo(2, mainWidth)) { | |||
| 343 | - | t.Fatalf("cursor was not clamped to the main content edge: %q", got) | |||
| 344 | - | } | |||
| 345 | - | } | |||
| 346 | - | ||||
| 347 | - | func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | |||
| 348 | - | t.Setenv("TERM_PROGRAM", "") | |||
| 349 | - | ||||
| 350 | - | var out bytes.Buffer | |||
| 351 | - | r := NewRenderer(&out) | |||
| 352 | - | r.SetTheme(Dark) | |||
| 353 | - | r.Resize(80, 5) | |||
| 354 | - | r.DrawRightBar( | |||
| 355 | - | []string{"chat"}, | |||
| 356 | - | []string{"input"}, | |||
| 357 | - | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |||
| 358 | - | 0, | |||
| 359 | - | 0, | |||
| 360 | - | ) | |||
| 361 | - | ||||
| 362 | - | out.Reset() | |||
| 363 | - | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | |||
| 364 | - | if r.rightBarActive { | |||
| 365 | - | t.Fatal("right-bar fallback left the viewport overlay active") | |||
| 366 | - | } | |||
| 367 | - | if r.logRawContentCols != 80 { | |||
| 368 | - | t.Fatalf("flow content width = %d, want full terminal width 80", r.logRawContentCols) | |||
| 369 | - | } | |||
| 370 | - | mainWidth, _, ok := RightBarColumns(80) | |||
| 371 | - | if !ok { | |||
| 372 | - | t.Fatal("right bar should fit before falling back to DrawLog") | |||
| 373 | - | } | |||
| 374 | - | clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth) | |||
| 375 | - | if !strings.Contains(out.String(), clearedRail) { | |||
| 376 | - | t.Fatalf("right-bar fallback did not clear the old rail: %q", out.String()) | |||
| 377 | - | } | |||
| 378 | - | if strings.Contains(out.String(), SeqClearScreenNoHome) || strings.Contains(out.String(), SeqClearScrollback) { | |||
| 379 | - | t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String()) | |||
| 380 | - | } | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | - | func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | |||
| 384 | - | var out bytes.Buffer | |||
| 385 | - | r := NewRenderer(&out) | |||
| 386 | - | r.SetTheme(Dark) | |||
| 387 | - | r.Resize(80, 5) | |||
| 388 | - | r.DrawRightBar( | |||
| 389 | - | []string{"chat"}, | |||
| 390 | - | []string{"input"}, | |||
| 391 | - | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Old", Lines: []string{"first"}}}, 26, 5), | |||
| 392 | - | 0, | |||
| 393 | - | 0, | |||
| 394 | - | ) | |||
| 395 | - | if !strings.Contains(stripANSI(out.String()), "Old") { | |||
| 396 | - | t.Fatalf("initial right-bar frame omitted widget: %q", out.String()) | |||
| 397 | - | } | |||
| 398 | - | ||||
| 399 | - | out.Reset() | |||
| 400 | - | r.DrawRightBar( | |||
| 401 | - | []string{"chat"}, | |||
| 402 | - | []string{"input"}, | |||
| 403 | - | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Updated", Lines: []string{"second"}}}, 26, 5), | |||
| 404 | - | 0, | |||
| 405 | - | 0, | |||
| 406 | - | ) | |||
| 407 | - | if !strings.Contains(stripANSI(out.String()), "Updated") { | |||
| 408 | - | t.Fatalf("right-bar update did not repaint changed content: %q", out.String()) | |||
| 409 | - | } | |||
| 410 | - | if !r.logInit { | |||
| 411 | - | t.Fatal("right-bar update abandoned the flow renderer") | |||
| 412 | - | } | |||
| 413 | - | ||||
| 414 | - | out.Reset() | |||
| 415 | - | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | |||
| 416 | - | if !r.logInit { | |||
| 417 | - | t.Fatal("flow renderer was not retained after right-bar clearing") | |||
| 418 | - | } | |||
| 419 | - | ||||
| 420 | - | out.Reset() | |||
| 421 | - | r.Resize(100, 6) | |||
| 422 | - | if out.Len() == 0 { | |||
| 423 | - | t.Fatal("resize did not invalidate the right-bar frame") | |||
| 424 | - | } | |||
| 425 | - | main, rail, ok := RightBarColumns(100) | |||
| 426 | - | if !ok { | |||
| 427 | - | t.Fatal("right bar should fit after resize") | |||
| 428 | - | } | |||
| 429 | - | if main+RightBarSeparatorWidth+rail != 100 { | |||
| 430 | - | t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail) | |||
| 431 | - | } | |||
| 432 | - | } | |||
| 433 | - | ||||
| 434 | - | func TestRendererDrawRightBarDimmedDimsSeparator(t *testing.T) { | |||
| 435 | - | var out bytes.Buffer | |||
| 436 | - | r := NewRenderer(&out) | |||
| 437 | - | r.SetTheme(Dark) | |||
| 438 | - | r.Resize(80, 5) | |||
| 439 | - | ||||
| 440 | - | r.DrawRightBarDimmed( | |||
| 441 | - | DimLines([]string{"chat"}), | |||
| 442 | - | DimLines([]string{"input"}), | |||
| 443 | - | DimLines(RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)), | |||
| 444 | - | 0, | |||
| 445 | - | 0, | |||
| 446 | - | ) | |||
| 447 | - | ||||
| 448 | - | separator := Dim(Dark.FGColor(Dark.Muted, "│")) | |||
| 449 | - | if !strings.Contains(out.String(), separator) { | |||
| 450 | - | t.Fatalf("dimmed right bar left its separator bright; missing %q in %q", separator, out.String()) | |||
| 451 | - | } | |||
| 452 | - | } | |||
M packages/tui/view.go +132 -0
| @@ -1,8 +1,10 @@ | |||||
| 1 | package tui | 1 | package tui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 7 | + | "io" | |||
| 6 | "os" | 8 | "os" | ||
| 7 | "regexp" | 9 | "regexp" | ||
| 8 | "strconv" | 10 | "strconv" | ||
| @@ -128,6 +130,8 @@ type View struct { | |||||
| 128 | // the box top edge without having to look back at the assistant | 130 | // the box top edge without having to look back at the assistant | ||
| 129 | // message that originated the call. Rebuilt on each Build(). | 131 | // message that originated the call. Rebuilt on each Build(). | ||
| 130 | toolCallLabels map[string]string | 132 | toolCallLabels map[string]string | ||
| 133 | + | toolCallNames map[string]string | |||
| 134 | + | toolCallArgs map[string]json.RawMessage | |||
| 131 | Streaming string // current assistant text delta | 135 | Streaming string // current assistant text delta | ||
| 132 | StreamingActive bool | 136 | StreamingActive bool | ||
| 133 | ToolCalls []ToolCallView // tool calls in flight or completed | 137 | ToolCalls []ToolCallView // tool calls in flight or completed | ||
| @@ -275,6 +279,8 @@ func (v *View) CloneForRender() *View { | |||||
| 275 | clone.toolPaths = cloneStringMap(v.toolPaths) | 279 | clone.toolPaths = cloneStringMap(v.toolPaths) | ||
| 276 | clone.toolStartLines = cloneIntMap(v.toolStartLines) | 280 | clone.toolStartLines = cloneIntMap(v.toolStartLines) | ||
| 277 | clone.toolCallLabels = cloneStringMap(v.toolCallLabels) | 281 | clone.toolCallLabels = cloneStringMap(v.toolCallLabels) | ||
| 282 | + | clone.toolCallNames = cloneStringMap(v.toolCallNames) | |||
| 283 | + | clone.toolCallArgs = cloneRawMessageMap(v.toolCallArgs) | |||
| 278 | clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh) | 284 | clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh) | ||
| 279 | if v.renderCache != nil { | 285 | if v.renderCache != nil { | ||
| 280 | clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache)) | 286 | clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache)) | ||
| @@ -303,6 +309,17 @@ func cloneStringMap(src map[string]string) map[string]string { | |||||
| 303 | return dst | 309 | return dst | ||
| 304 | } | 310 | } | ||
| 305 | 311 | ||||
| 312 | + | func cloneRawMessageMap(src map[string]json.RawMessage) map[string]json.RawMessage { | |||
| 313 | + | if src == nil { | |||
| 314 | + | return nil | |||
| 315 | + | } | |||
| 316 | + | dst := make(map[string]json.RawMessage, len(src)) | |||
| 317 | + | for key, value := range src { | |||
| 318 | + | dst[key] = append(json.RawMessage(nil), value...) | |||
| 319 | + | } | |||
| 320 | + | return dst | |||
| 321 | + | } | |||
| 322 | + | ||||
| 306 | func cloneIntMap(src map[string]int) map[string]int { | 323 | func cloneIntMap(src map[string]int) map[string]int { | ||
| 307 | if src == nil { | 324 | if src == nil { | ||
| 308 | return nil | 325 | return nil | ||
| @@ -329,6 +346,8 @@ func (v *View) AdoptRenderCacheFrom(snapshot *View) { | |||||
| 329 | v.toolPaths = snapshot.toolPaths | 346 | v.toolPaths = snapshot.toolPaths | ||
| 330 | v.toolStartLines = snapshot.toolStartLines | 347 | v.toolStartLines = snapshot.toolStartLines | ||
| 331 | v.toolCallLabels = snapshot.toolCallLabels | 348 | v.toolCallLabels = snapshot.toolCallLabels | ||
| 349 | + | v.toolCallNames = snapshot.toolCallNames | |||
| 350 | + | v.toolCallArgs = snapshot.toolCallArgs | |||
| 332 | v.toolPathRevision = snapshot.toolPathRevision | 351 | v.toolPathRevision = snapshot.toolPathRevision | ||
| 333 | } | 352 | } | ||
| 334 | 353 | ||||
| @@ -698,6 +717,8 @@ func (v *View) refreshToolPaths() { | |||||
| 698 | v.toolPaths = map[string]string{} | 717 | v.toolPaths = map[string]string{} | ||
| 699 | v.toolStartLines = map[string]int{} | 718 | v.toolStartLines = map[string]int{} | ||
| 700 | v.toolCallLabels = map[string]string{} | 719 | v.toolCallLabels = map[string]string{} | ||
| 720 | + | v.toolCallNames = map[string]string{} | |||
| 721 | + | v.toolCallArgs = map[string]json.RawMessage{} | |||
| 701 | for _, m := range v.Messages { | 722 | for _, m := range v.Messages { | ||
| 702 | for _, c := range m.Content { | 723 | for _, c := range m.Content { | ||
| 703 | if tc, ok := c.(provider.ToolCallBlock); ok { | 724 | if tc, ok := c.(provider.ToolCallBlock); ok { | ||
| @@ -708,6 +729,8 @@ func (v *View) refreshToolPaths() { | |||||
| 708 | v.toolStartLines[tc.ID] = off | 729 | v.toolStartLines[tc.ID] = off | ||
| 709 | } | 730 | } | ||
| 710 | v.toolCallLabels[tc.ID] = tc.Name + " " + ShortArgs(tc.Name, tc.Arguments) | 731 | v.toolCallLabels[tc.ID] = tc.Name + " " + ShortArgs(tc.Name, tc.Arguments) | ||
| 732 | + | v.toolCallNames[tc.ID] = tc.Name | |||
| 733 | + | v.toolCallArgs[tc.ID] = tc.Arguments | |||
| 711 | } | 734 | } | ||
| 712 | } | 735 | } | ||
| 713 | } | 736 | } | ||
| @@ -922,6 +945,103 @@ func fnv64aWrite(h uint64, p []byte) uint64 { | |||||
| 922 | return h | 945 | return h | ||
| 923 | } | 946 | } | ||
| 924 | 947 | ||||
| 948 | + | type planUpdateArgs struct { | |||
| 949 | + | Explanation string `json:"explanation,omitempty"` | |||
| 950 | + | Plan []planUpdateStep `json:"plan"` | |||
| 951 | + | } | |||
| 952 | + | ||||
| 953 | + | type planUpdateStep struct { | |||
| 954 | + | Step string `json:"step"` | |||
| 955 | + | Status string `json:"status"` | |||
| 956 | + | } | |||
| 957 | + | ||||
| 958 | + | type planUpdateWire struct { | |||
| 959 | + | Explanation *string `json:"explanation"` | |||
| 960 | + | Plan *[]planUpdateStepWire `json:"plan"` | |||
| 961 | + | } | |||
| 962 | + | ||||
| 963 | + | type planUpdateStepWire struct { | |||
| 964 | + | Step *string `json:"step"` | |||
| 965 | + | Status *string `json:"status"` | |||
| 966 | + | } | |||
| 967 | + | ||||
| 968 | + | func parsePlanUpdate(raw json.RawMessage) (planUpdateArgs, bool) { | |||
| 969 | + | var wire planUpdateWire | |||
| 970 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) | |||
| 971 | + | decoder.DisallowUnknownFields() | |||
| 972 | + | if err := decoder.Decode(&wire); err != nil { | |||
| 973 | + | return planUpdateArgs{}, false | |||
| 974 | + | } | |||
| 975 | + | if err := decoder.Decode(&struct{}{}); err != io.EOF || wire.Plan == nil { | |||
| 976 | + | return planUpdateArgs{}, false | |||
| 977 | + | } | |||
| 978 | + | ||||
| 979 | + | update := planUpdateArgs{Plan: make([]planUpdateStep, len(*wire.Plan))} | |||
| 980 | + | if wire.Explanation != nil { | |||
| 981 | + | update.Explanation = *wire.Explanation | |||
| 982 | + | } | |||
| 983 | + | for idx, step := range *wire.Plan { | |||
| 984 | + | if step.Step == nil || step.Status == nil { | |||
| 985 | + | return planUpdateArgs{}, false | |||
| 986 | + | } | |||
| 987 | + | switch *step.Status { | |||
| 988 | + | case "pending", "in_progress", "completed": | |||
| 989 | + | default: | |||
| 990 | + | return planUpdateArgs{}, false | |||
| 991 | + | } | |||
| 992 | + | update.Plan[idx] = planUpdateStep{Step: *step.Step, Status: *step.Status} | |||
| 993 | + | } | |||
| 994 | + | return update, true | |||
| 995 | + | } | |||
| 996 | + | ||||
| 997 | + | func (v *View) renderPlanUpdate(update planUpdateArgs, width int) []string { | |||
| 998 | + | lines := []string{v.Theme.FGColor(v.Theme.Muted, "• ") + Bold("Updated Plan")} | |||
| 999 | + | bodyWidth := width - 4 | |||
| 1000 | + | if bodyWidth < 1 { | |||
| 1001 | + | bodyWidth = 1 | |||
| 1002 | + | } | |||
| 1003 | + | var body []string | |||
| 1004 | + | if explanation := strings.TrimSpace(update.Explanation); explanation != "" { | |||
| 1005 | + | for _, line := range wrapLine(explanation, bodyWidth, "") { | |||
| 1006 | + | body = append(body, Dim(Italic(line))) | |||
| 1007 | + | } | |||
| 1008 | + | } | |||
| 1009 | + | if len(update.Plan) == 0 { | |||
| 1010 | + | body = append(body, Dim(Italic("(no steps provided)"))) | |||
| 1011 | + | } else { | |||
| 1012 | + | for _, step := range update.Plan { | |||
| 1013 | + | marker := "□ " | |||
| 1014 | + | style := func(text string) string { return Dim(text) } | |||
| 1015 | + | switch step.Status { | |||
| 1016 | + | case "completed": | |||
| 1017 | + | marker = "✓ " | |||
| 1018 | + | case "in_progress": | |||
| 1019 | + | style = func(text string) string { return v.Theme.FGColor(v.Theme.Accent, Bold(text)) } | |||
| 1020 | + | } | |||
| 1021 | + | stepWidth := bodyWidth - 2 | |||
| 1022 | + | if stepWidth < 1 { | |||
| 1023 | + | stepWidth = 1 | |||
| 1024 | + | } | |||
| 1025 | + | wrapped := wrapLine(step.Step, stepWidth, "") | |||
| 1026 | + | if len(wrapped) == 0 { | |||
| 1027 | + | wrapped = []string{""} | |||
| 1028 | + | } | |||
| 1029 | + | body = append(body, style(marker+wrapped[0])) | |||
| 1030 | + | for _, continuation := range wrapped[1:] { | |||
| 1031 | + | body = append(body, style(" "+continuation)) | |||
| 1032 | + | } | |||
| 1033 | + | } | |||
| 1034 | + | } | |||
| 1035 | + | for idx, line := range body { | |||
| 1036 | + | prefix := " " | |||
| 1037 | + | if idx == 0 { | |||
| 1038 | + | prefix = v.Theme.FGColor(v.Theme.Muted, " └ ") | |||
| 1039 | + | } | |||
| 1040 | + | lines = append(lines, prefix+line) | |||
| 1041 | + | } | |||
| 1042 | + | return lines | |||
| 1043 | + | } | |||
| 1044 | + | ||||
| 925 | func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string { | 1045 | func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string { | ||
| 926 | var lines []string | 1046 | var lines []string | ||
| 927 | 1047 | ||||
| @@ -1032,6 +1152,12 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 1032 | case provider.RoleTool: | 1152 | case provider.RoleTool: | ||
| 1033 | for _, c := range m.Content { | 1153 | for _, c := range m.Content { | ||
| 1034 | if tr, ok := c.(provider.ToolResultBlock); ok { | 1154 | if tr, ok := c.(provider.ToolResultBlock); ok { | ||
| 1155 | + | if !tr.IsError && v.toolCallNames[tr.CallID] == "update_plan" { | |||
| 1156 | + | if update, ok := parsePlanUpdate(v.toolCallArgs[tr.CallID]); ok { | |||
| 1157 | + | lines = append(lines, v.renderPlanUpdate(update, width)...) | |||
| 1158 | + | continue | |||
| 1159 | + | } | |||
| 1160 | + | } | |||
| 1035 | color := v.Theme.ToolOut | 1161 | color := v.Theme.ToolOut | ||
| 1036 | if tr.IsError { | 1162 | if tr.IsError { | ||
| 1037 | color = v.Theme.Error | 1163 | color = v.Theme.Error | ||
| @@ -1237,6 +1363,12 @@ func hashThemeColor(h uint64, c TerminalColor) uint64 { | |||||
| 1237 | } | 1363 | } | ||
| 1238 | 1364 | ||||
| 1239 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | 1365 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | ||
| 1366 | + | if tc.Name == "update_plan" && tc.Done && !tc.Error { | |||
| 1367 | + | if update, ok := parsePlanUpdate(json.RawMessage(tc.RawJSONBuf)); ok { | |||
| 1368 | + | return v.renderPlanUpdate(update, width) | |||
| 1369 | + | } | |||
| 1370 | + | } | |||
| 1371 | + | ||||
| 1240 | var lines []string | 1372 | var lines []string | ||
| 1241 | 1373 | ||||
| 1242 | // Header label. While the call is still streaming, prefer the | 1374 | // Header label. While the call is still streaming, prefer the | ||
A packages/tui/view_plan_test.go +148 -0
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestViewRendersUpdatePlanAsChecklist(t *testing.T) { | |||
| 12 | + | callID := "plan-1" | |||
| 13 | + | args := json.RawMessage(`{ | |||
| 14 | + | "explanation":"Finished discovery", | |||
| 15 | + | "plan":[ | |||
| 16 | + | {"step":"Map the implementation","status":"completed"}, | |||
| 17 | + | {"step":"Add the built-in tool","status":"in_progress"}, | |||
| 18 | + | {"step":"Verify the behavior","status":"pending"} | |||
| 19 | + | ] | |||
| 20 | + | }`) | |||
| 21 | + | view := &View{Theme: Dark, Messages: []provider.Message{ | |||
| 22 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 23 | + | provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: args}, | |||
| 24 | + | }}, | |||
| 25 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |||
| 26 | + | provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}}, | |||
| 27 | + | }}, | |||
| 28 | + | }} | |||
| 29 | + | ||||
| 30 | + | plain := stripANSI(strings.Join(view.Build(60), "\n")) | |||
| 31 | + | for _, want := range []string{ | |||
| 32 | + | "Updated Plan", | |||
| 33 | + | "Finished discovery", | |||
| 34 | + | "✓ Map the implementation", | |||
| 35 | + | "□ Add the built-in tool", | |||
| 36 | + | "□ Verify the behavior", | |||
| 37 | + | } { | |||
| 38 | + | if !strings.Contains(plain, want) { | |||
| 39 | + | t.Fatalf("rendered plan omitted %q:\n%s", want, plain) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") { | |||
| 43 | + | t.Fatalf("rendered plan leaked generic tool chrome:\n%s", plain) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestViewRendersLiveUpdatePlanAsChecklist(t *testing.T) { | |||
| 48 | + | args := json.RawMessage(`{"plan":[{"step":"Implement","status":"in_progress"}]}`) | |||
| 49 | + | view := &View{Theme: Dark} | |||
| 50 | + | plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{ | |||
| 51 | + | ID: "plan-1", | |||
| 52 | + | Name: "update_plan", | |||
| 53 | + | RawJSONBuf: string(args), | |||
| 54 | + | Result: "Plan updated", | |||
| 55 | + | Done: true, | |||
| 56 | + | }, 50), "\n")) | |||
| 57 | + | if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") { | |||
| 58 | + | t.Fatalf("live plan rendering = %q", plain) | |||
| 59 | + | } | |||
| 60 | + | if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") { | |||
| 61 | + | t.Fatalf("live plan leaked generic tool chrome: %q", plain) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) { | |||
| 66 | + | callID := "plan-1" | |||
| 67 | + | args := json.RawMessage(`{"plan":[{"step":"Implement","status":"in_progress"}]}`) | |||
| 68 | + | messages := []provider.Message{ | |||
| 69 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 70 | + | provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: args}, | |||
| 71 | + | }}, | |||
| 72 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |||
| 73 | + | provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}}, | |||
| 74 | + | }}, | |||
| 75 | + | } | |||
| 76 | + | seed := func() *View { | |||
| 77 | + | return &View{ | |||
| 78 | + | Theme: Dark, | |||
| 79 | + | Messages: messages, | |||
| 80 | + | MessagesRevision: 7, | |||
| 81 | + | toolPathRevision: 7, | |||
| 82 | + | toolCallNames: map[string]string{callID: "update_plan"}, | |||
| 83 | + | toolCallArgs: map[string]json.RawMessage{callID: args}, | |||
| 84 | + | toolCallLabels: map[string]string{callID: "update_plan"}, | |||
| 85 | + | toolPaths: map[string]string{}, | |||
| 86 | + | toolStartLines: map[string]int{}, | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | assertChecklist := func(t *testing.T, view *View) { | |||
| 90 | + | t.Helper() | |||
| 91 | + | plain := stripANSI(strings.Join(view.Build(50), "\n")) | |||
| 92 | + | if !strings.Contains(plain, "Updated Plan") || strings.Contains(plain, "Plan updated") { | |||
| 93 | + | t.Fatalf("plan rendering = %q", plain) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | original := seed() | |||
| 98 | + | assertChecklist(t, original.CloneForRender()) | |||
| 99 | + | ||||
| 100 | + | owner := &View{Theme: Dark, Messages: messages, MessagesRevision: 7} | |||
| 101 | + | owner.AdoptRenderCacheFrom(seed()) | |||
| 102 | + | assertChecklist(t, owner) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestViewFallsBackForMalformedUpdatePlan(t *testing.T) { | |||
| 106 | + | cases := []string{ | |||
| 107 | + | `{"plan":[{"status":"pending"}]}`, | |||
| 108 | + | `{"plan":[{"step":"work"}]}`, | |||
| 109 | + | `{"plan":[{"step":"work","status":"unknown"}]}`, | |||
| 110 | + | `{"plan":[{"step":"work","status":"pending","extra":true}]}`, | |||
| 111 | + | `{"plan":[],"extra":true}`, | |||
| 112 | + | `{"plan":[]} trailing`, | |||
| 113 | + | } | |||
| 114 | + | for _, raw := range cases { | |||
| 115 | + | t.Run(raw, func(t *testing.T) { | |||
| 116 | + | callID := "plan-1" | |||
| 117 | + | view := &View{Theme: Dark, Messages: []provider.Message{ | |||
| 118 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 119 | + | provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: json.RawMessage(raw)}, | |||
| 120 | + | }}, | |||
| 121 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |||
| 122 | + | provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}}, | |||
| 123 | + | }}, | |||
| 124 | + | }} | |||
| 125 | + | plain := stripANSI(strings.Join(view.Build(50), "\n")) | |||
| 126 | + | if strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "Plan updated") { | |||
| 127 | + | t.Fatalf("malformed plan did not use generic fallback: %q", plain) | |||
| 128 | + | } | |||
| 129 | + | }) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestViewRendersEmptyUpdatePlan(t *testing.T) { | |||
| 134 | + | callID := "plan-1" | |||
| 135 | + | view := &View{Theme: Dark, Messages: []provider.Message{ | |||
| 136 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 137 | + | provider.ToolCallBlock{ID: callID, Name: "update_plan", Arguments: json.RawMessage(`{"plan":[]}`)}, | |||
| 138 | + | }}, | |||
| 139 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |||
| 140 | + | provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}}, | |||
| 141 | + | }}, | |||
| 142 | + | }} | |||
| 143 | + | ||||
| 144 | + | plain := stripANSI(strings.Join(view.Build(40), "\n")) | |||
| 145 | + | if !strings.Contains(plain, "(no steps provided)") { | |||
| 146 | + | t.Fatalf("empty plan rendering = %q", plain) | |||
| 147 | + | } | |||
| 148 | + | } | |||