detached 7a5217b

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 UnverifiedB5690EEEBB952194 The 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.
99
1010 - one static binary.
1111 - 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.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - 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
255255 - `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).
256256 - `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).
257257 - `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.
258259 - `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.
259260
260261 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`,
921922 | `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. |
922923 | `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. |
923924 | `ctrl+d` | Exit on empty input. |
924-| `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |
925925 | `ctrl+l` | Redraw the screen. |
926926 | `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. |
927927 | `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
10061006
10071007 ### Reference
10081008
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`).
10101010
10111011 ## Skills
10121012
M docs/extensions.md +14 -32
@@ -226,11 +226,11 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
226226
227227 Tool names live in the same namespace as built-in tools (`read`,
228228 `write`, `edit`, `bash`, `create_worktree`, `grep`, `lsp`, `web_search`,
229-`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `skill`).
230-Conflicts with active built-ins are silently shadowed by the built-in. Every
231-native public-web capability name, plus `grep`, `schedule`, and `update_goal`,
232-is reserved even when it is unavailable, so an extension cannot claim or
233-replace it.
229+`web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,
230+`skill`). Conflicts with active built-ins are silently shadowed by the built-in.
231+Every native public-web capability name, plus `grep`, `schedule`, `update_goal`,
232+and `update_plan`, is reserved even when it is unavailable, so an extension
233+cannot claim or replace it.
234234
235235 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`:
236236
@@ -425,22 +425,14 @@ Sets or replaces one status item owned by the extension. Sending an empty
425425
426426 #### `widget` (one-way, persistent)
427427
428-Sets or replaces a compact widget. `position` is a host-defined placement
429-hint with these interactive-zut values:
430-
431-- `"above_input"` keeps the widget in the existing persistent chrome above
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.
428+Sets or replaces a compact widget. Interactive zut renders widgets in the
429+persistent chrome above the editor. Use `"above_input"` as the `position`;
430+empty and unknown positions resolve to the same placement. Use `open_panel`
431+for interaction or navigation.
440432
441433 ```json
442-{"type":"widget","id":"plan","position":"right_bar",
443- "title":"Tasked phases","lines":["Current phase: parse","[ ] validate inputs"]}
434+{"type":"widget","id":"progress","position":"above_input",
435+ "title":"Progress","lines":["Indexing files","12/20 complete"]}
444436 ```
445437
446438 #### `widget_clear` (one-way)
@@ -722,12 +714,11 @@ latest persisted snapshot. A tool can return opaque state with
722714 request. `SetStatus`, `SetWidget`, and their clear methods update persistent
723715 interactive chrome without entering the transcript.
724716
725-For Go extensions, use the SDK constants so the host can choose the responsive
726-layout without extension-specific terminal code:
717+For Go extensions, use the SDK placement constant:
727718
728719 ```go
729-e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines)
730-e.ClearWidget("plan")
720+e.SetWidget("progress", ext.WidgetPositionAboveInput, "Progress", lines)
721+e.ClearWidget("progress")
731722 ```
732723
733724 The SDK has four interceptor hooks, all optional:
@@ -770,17 +761,8 @@ See:
770761 - `examples/extensions/guard/` — event subscriptions + tool-call
771762 interception (refuses dangerous bash patterns)
772763 - `examples/extensions/todo/` — interactive persistent panel + tool
773-- `examples/extensions/tasked-phases/` — spec, phased checklist, persistent tool, and `/phases` panel
774764 - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool
775765
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-
784766 ### Hot reload
785767
786768 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`.
204204 | `tool_call` | `id`, `name`, `args` | The model proposed a tool call |
205205 | `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute |
206206 | `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`. |
207208 | `tool_result` | `id`, `is_error`, `content` | Tool finished |
208209 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
209210 | `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`.
212213 | `error` | `message` | Non-fatal error message |
213214 | `compact_done` | `summary` | Compaction finished, summary text included |
214215 | `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. |
216217 | `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. |
217218 | `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. |
218219
M docs/skills.md +0 -1
@@ -131,7 +131,6 @@ are omitted from the model's startup context.
131131 See `examples/skills/` for starter skills:
132132
133133 - `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
135134 - `test-fix/` — diagnose + minimally fix a failing test
136135
137136 ## 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, &params); 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) {
100100 // Web capability and other native names remain reserved even when their
101101 // policy excludes the current session. An extension must not turn a
102102 // 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 {
104104 continue
105105 }
106106 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1232,6 +1232,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
12321232 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
12331233 "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
12341234 "update_goal": &tools.UpdateGoalTool{},
1235+ "update_plan": &tools.UpdatePlanTool{},
12351236 }
12361237 if webSearchAllowedForRegistry(args) {
12371238 for name, tool := range tools.NewWebTools() {
@@ -1343,7 +1344,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
13431344 return false
13441345 }
13451346
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"}
13471348
13481349 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
13491350 var out []ToolSummary
M packages/agent/build_grep_test.go +16 -0
@@ -30,6 +30,22 @@ func TestBuildToolRegistryWebCapabilityBundle(t *testing.T) {
3030 }
3131 }
3232
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+
3349 func TestBuildToolRegistryGrepSelectionAndDefaults(t *testing.T) {
3450 cwd := t.TempDir()
3551
M packages/agent/ext/ext.go +8 -6
@@ -422,9 +422,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
422422 //
423423 // Conflicts with active built-in tool names (read, write, edit, bash,
424424 // 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.
426-// Every native public-web name, plus grep, schedule, and update_goal, is
427-// reserved even when unavailable, so an extension cannot claim or replace it.
425+// schedule, update_goal, update_plan, and skill) are silently shadowed by the
426+// built-in. Every native public-web name, plus grep, schedule, update_goal, and
427+// update_plan, is reserved even when unavailable, so an extension cannot claim
428+// or replace it.
428429 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
429430 e.registerTool(name, description, schema, false, fn)
430431 }
@@ -527,13 +528,14 @@ const (
527528 // WidgetPositionAboveInput keeps a widget in the persistent chrome above
528529 // the editor. It is the default for empty and unknown positions.
529530 WidgetPositionAboveInput = extproto.WidgetPositionAboveInput
530- // WidgetPositionRightBar keeps a widget in the host-owned side rail when
531- // the terminal is wide enough; narrow terminals fall back above the input.
531+ // WidgetPositionRightBar is retained for source compatibility. Widgets use
532+ // the above-input placement because the right sidebar no longer exists.
533+ // Deprecated: use WidgetPositionAboveInput.
532534 WidgetPositionRightBar = extproto.WidgetPositionRightBar
533535 )
534536
535537 // SetWidget replaces one persistent widget owned by this extension. Use
536-// WidgetPositionAboveInput or WidgetPositionRightBar for the position.
538+// WidgetPositionAboveInput for the position.
537539 func (e *Extension) SetWidget(id, position, title string, lines []string) {
538540 _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines})
539541 }
M packages/agent/ext/ext_test.go +2 -2
@@ -283,13 +283,13 @@ func TestPersistentChromeFrames(t *testing.T) {
283283 t.Fatalf("status = %+v", sf)
284284 }
285285
286- h.ext.SetWidget("plan", WidgetPositionRightBar, "Plan", []string{"one"})
286+ h.ext.SetWidget("plan", WidgetPositionAboveInput, "Plan", []string{"one"})
287287 widget := h.drainUntil(t, "widget")
288288 var wf extproto.WidgetFromExt
289289 if err := json.Unmarshal(widget.raw, &wf); err != nil {
290290 t.Fatal(err)
291291 }
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" {
293293 t.Fatalf("widget = %+v", wf)
294294 }
295295
M packages/agent/extproto/extproto.go +8 -18
@@ -17,10 +17,7 @@
1717 // correlate; events and notifications never carry an ID.
1818 package extproto
1919
20-import (
21- "encoding/json"
22- "strings"
23-)
20+import "encoding/json"
2421
2522 // ProtocolVersion is the extension protocol major. Version 2 introduces the
2623 // zut_version wire key and requires both sides to reject mismatched majors.
@@ -144,25 +141,18 @@ type StatusFromExt struct {
144141
145142 const (
146143 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
148147 )
149148
150-// NormalizeWidgetPosition returns the host's canonical widget placement.
151-// Empty and unknown values intentionally retain the historical above-input
152-// behavior so older extensions and future placement hints remain compatible.
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-}
149+// NormalizeWidgetPosition keeps extension widgets in the single supported
150+// above-input placement. Unknown and removed placement hints remain compatible.
151+func NormalizeWidgetPosition(string) string { return WidgetPositionAboveInput }
161152
162153 // WidgetFromExt sets or replaces one persistent widget owned by an
163154 // extension. Position is a host-defined placement hint. Interactive hosts
164-// support "above_input" and "right_bar"; empty and unknown values retain
165-// the historical "above_input" behavior.
155+// render widgets above the input; empty and unknown values use that placement.
166156 type WidgetFromExt struct {
167157 Type string `json:"type"`
168158 ID string `json:"id"`
M packages/agent/extproto/widget_test.go +8 -9
@@ -7,11 +7,10 @@ import (
77
88 func TestNormalizeWidgetPosition(t *testing.T) {
99 cases := map[string]string{
10- WidgetPositionRightBar: WidgetPositionRightBar,
11- " RIGHT_BAR ": WidgetPositionRightBar,
12- WidgetPositionAboveInput: WidgetPositionAboveInput,
13- "": WidgetPositionAboveInput,
14- "unknown": WidgetPositionAboveInput,
10+ WidgetPositionRightBar: WidgetPositionAboveInput,
11+ "right_bar": WidgetPositionAboveInput,
12+ "": WidgetPositionAboveInput,
13+ "unknown": WidgetPositionAboveInput,
1514 }
1615 for input, want := range cases {
1716 if got := NormalizeWidgetPosition(input); got != want {
@@ -29,11 +28,11 @@ func TestWidgetFrameKeepsUnknownAndMissingPositionsCompatible(t *testing.T) {
2928 t.Fatalf("missing position normalized to %q, want %q", got, WidgetPositionAboveInput)
3029 }
3130
32- var rightBar WidgetFromExt
33- if err := json.Unmarshal([]byte(`{"type":"widget","id":"plan","position":"right_bar","title":"Plan"}`), &rightBar); err != nil {
31+ var removedPlacement WidgetFromExt
32+ if err := json.Unmarshal([]byte(`{"type":"widget","id":"plan","position":"right_bar","title":"Plan"}`), &removedPlacement); err != nil {
3433 t.Fatal(err)
3534 }
36- if got := NormalizeWidgetPosition(rightBar.Position); got != WidgetPositionRightBar {
37- t.Fatalf("right_bar position normalized to %q, want %q", got, WidgetPositionRightBar)
35+ if got := NormalizeWidgetPosition(removedPlacement.Position); got != WidgetPositionAboveInput {
36+ t.Fatalf("removed right_bar position normalized to %q, want %q", got, WidgetPositionAboveInput)
3837 }
3938 }
M packages/agent/modes/ext_notes_test.go +12 -92
@@ -1,7 +1,6 @@
11 package modes
22
33 import (
4- "context"
54 "fmt"
65 "strings"
76 "testing"
@@ -86,32 +85,10 @@ func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) {
8685 }
8786 }
8887
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-
11188 func TestPersistentExtensionStatusAndWidget(t *testing.T) {
11289 i := newNotesTestInteractive()
113- i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked")
114- i.SetWidget("tasked-phases", "plan", "above_input", "Tasked phases", []string{"Current phase: parse", "[ ] read files"})
90+ i.SetStatus("sample", "progress", "success", "2/4 tasks checked")
91+ i.SetWidget("sample", "plan", "above_input", "Plan", []string{"Current phase: parse", "[ ] read files"})
11592
11693 i.mu.Lock()
11794 lines := i.extensionChromeLinesLocked(80)
@@ -119,55 +96,29 @@ func TestPersistentExtensionStatusAndWidget(t *testing.T) {
11996 widgetKey := i.extensionWidgetsKeyLocked()
12097 i.mu.Unlock()
12198 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") {
123100 t.Fatalf("persistent extension chrome = %q", joined)
124101 }
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") {
126103 t.Fatalf("status cache key = %q", statusKey)
127104 }
128- if !strings.Contains(widgetKey, "tasked-phases/plan/above_input/Tasked phases") {
105+ if !strings.Contains(widgetKey, "sample/plan/above_input/Plan") {
129106 t.Fatalf("widget cache key = %q", widgetKey)
130107 }
131108
132- i.SetStatus("tasked-phases", "progress", "", "")
133- i.ClearWidget("tasked-phases", "plan")
109+ i.SetStatus("sample", "progress", "", "")
110+ i.ClearWidget("sample", "plan")
134111 if len(i.extStatuses) != 0 || len(i.extWidgets) != 0 {
135112 t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
136113 }
137114 }
138115
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-
165116 func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) {
166117 i := newNotesTestInteractive()
167118 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"})
169120 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"})
171122 i.extNotes = []string{"[gone] old note", "[keep] keep note"}
172123
173124 i.ClearExtensionChrome("gone")
@@ -189,46 +140,15 @@ func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) {
189140 }
190141 }
191142
192-func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) {
143+func TestInteractiveRedrawPlacesWidgetAboveInput(t *testing.T) {
193144 term := &alertTestTerminal{}
194145 i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
195146 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"})
197148 i.redraw()
198149
199150 out := stripANSIBytes(term.String())
200151 if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") {
201- t.Fatalf("wide redraw omitted right-bar content: %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")
152+ t.Fatalf("redraw omitted above-input widget: %q", out)
233153 }
234154 }
M packages/agent/modes/help.go +0 -1
@@ -20,7 +20,6 @@ var helpKeyRows = [][2]string{
2020 {"ctrl+u / ctrl+k", "delete to start / end of line"},
2121 {"ctrl+a / ctrl+e", "jump to start / end of line"},
2222 {"alt+← / alt+→", "jump one word back / forward"},
23- {"ctrl+b", "toggle the right sidebar"},
2423 {"ctrl+l", "redraw the screen"},
2524 {"ctrl+o", "expand / collapse long tool results"},
2625 {"pgup / pgdn", "scroll the chat one page up / down"},
M packages/agent/modes/help_test.go +0 -3
@@ -9,9 +9,6 @@ import (
99
1010 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {
1111 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- }
1512 if strings.Contains(without, "/llama") {
1613 t.Fatalf("help exposed /llama without login: %q", without)
1714 }
M packages/agent/modes/interactive.go +0 -5
@@ -702,7 +702,6 @@ type Interactive struct {
702702 reloadStatusSeq uint64
703703 extStatuses map[string]map[string]extensionStatus
704704 extWidgets map[string]map[string]extensionWidget
705- rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
706705 liveBlock []string // live streaming/tool progress rendered outside scrollback
707706 helpBlock []string // rendered above the chat when /help was typed
708707 sessionInfoBlocks []sessionInfoBlock
@@ -1616,10 +1615,6 @@ func (i *Interactive) Run(ctx context.Context) error {
16161615 const maxExtensionWidgetRows = 12
16171616 const maxExtensionStatusRows = 6
16181617
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-
16231618 // sessionsRoot returns the session namespace for this run.
16241619
16251620 // 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) {
637637 if i.ed.IsEmpty() && !i.busy {
638638 return true
639639 }
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
647640 case tui.KeyCtrlL:
648641 i.requestRendererClear()
649642 i.invalidate()
M packages/agent/modes/interactive_rendering.go +1 -65
@@ -6,7 +6,6 @@ import (
66 "strings"
77 "time"
88
9- "github.com/bnema/zut/packages/agent/extproto"
109 "github.com/bnema/zut/packages/agent/skills"
1110 "github.com/bnema/zut/packages/core"
1211 "github.com/bnema/zut/packages/provider"
@@ -122,38 +121,7 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
122121 }
123122 return sb.String()
124123 }
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-}
143124 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 {
157125 var out []string
158126 bodyWidth := cols - 2
159127 if bodyWidth < 1 {
@@ -178,9 +146,6 @@ func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool
178146 }
179147 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
180148 widget := i.extWidgets[extName][id]
181- if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
182- continue
183- }
184149 label := " [" + extName + "]"
185150 if widget.Title != "" {
186151 label += " " + widget.Title
@@ -500,17 +465,6 @@ func (i *Interactive) redraw() {
500465
501466 cols, rows := i.cfg.Terminal.Size()
502467 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
514468 i.renderOutsideLock = true
515469 chat := i.cachedChatLocked(mainCols)
516470 i.renderOutsideLock = false
@@ -783,7 +737,7 @@ func (i *Interactive) redraw() {
783737 queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
784738 }
785739
786- extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
740+ extensionLines := i.extensionChromeLinesLocked(mainCols)
787741
788742 // Bottom-sticky sections (always visible, never scroll). Each
789743 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -1077,15 +1031,8 @@ func (i *Interactive) redraw() {
10771031 i.mu.Unlock()
10781032 if modalBackdrop {
10791033 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- }
10841034 pane, paneCursorRow, paneCursorCol := i.floatingPane.Compose(theme, dialogID, dialogTitle, background, dialog, cols, rows, dialogCursorRow, dialogCursorCol)
10851035 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)
10891036 } else {
10901037 _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
10911038 i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
@@ -1150,17 +1097,6 @@ func floatingBackgroundFrame(chat, bottom []string, rows int) []string {
11501097 return frame
11511098 }
11521099
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-
11641100 func hasImageEscape(line string) bool {
11651101 return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
11661102 }
M packages/agent/modes/json.go +7 -0
@@ -109,6 +109,13 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
109109 case core.EvToolProgress:
110110 m["id"] = e.ID
111111 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
112119 case core.EvToolResult:
113120 m["id"] = e.ID
114121 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) {
8383 }
8484 }
8585
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+
86105 func TestEventToJSONActivityLifecycle(t *testing.T) {
87106 request := EventToJSON(core.EvRequestStarted{
88107 Provider: "anthropic",
M packages/agent/sdk/sdk.go +5 -6
@@ -76,12 +76,11 @@ type Config struct {
7676 // BaseURL overrides the provider base url (for tests / proxies).
7777 BaseURL string
7878
79- // Tools is the list of tools to enable. Nil/empty = all ordinary built-in
80- // tools (read, write, edit, bash, create_worktree, grep, lsp). The public-web
81- // capability is excluded unless this list explicitly contains "web_search";
82- // that selector enables web_search, web_open, web_find, and web_click. Pass an
83- // empty-but-non-nil slice
84- // (e.g. []string{}) plus NoTools=true to disable everything.
79+ // Tools is the list of tools to enable. Nil/empty enables the ordinary
80+ // built-ins: read, write, edit, bash, create_worktree, grep, lsp, update_goal,
81+ // and update_plan. The public-web capability is excluded unless this list
82+ // explicitly contains "web_search"; that selector enables web_search,
83+ // web_open, web_find, and web_click. Pass NoTools=true to disable everything.
8584 Tools []string
8685
8786 // NoTools disables every tool. Useful for chat-only embeddings.
M packages/agent/sdk/types.go +17 -0
@@ -42,6 +42,10 @@ type Event struct {
4242 IsError bool `json:"is_error,omitempty"`
4343 Result []ContentBlock `json:"content,omitempty"`
4444
45+ // plan_update
46+ Explanation *string `json:"explanation,omitempty"`
47+ Plan *[]PlanStep `json:"plan,omitempty"`
48+
4549 // usage
4650 Usage *Usage `json:"usage,omitempty"`
4751 Cumulative *Usage `json:"cumulative,omitempty"`
@@ -59,6 +63,12 @@ type Event struct {
5963 Error string `json:"error,omitempty"`
6064 }
6165
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+
6272 // Message is one transcript entry — a user prompt, assistant reply,
6373 // or tool result.
6474 type Message struct {
@@ -189,6 +199,13 @@ func toEvent(ev core.AgentEvent) Event {
189199 case core.EvToolProgress:
190200 out.ID = e.ID
191201 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
192209 case core.EvToolResult:
193210 out.ID = e.ID
194211 out.IsError = e.Result.IsError
M packages/agent/sdk/types_test.go +26 -0
@@ -35,6 +35,32 @@ func TestToEventPreservesCacheDiagnostics(t *testing.T) {
3535 }
3636 }
3737
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+
3864 func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) {
3965 retry := toEvent(core.EvRetryScheduled{
4066 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
13011301 if a.OnToolResult != nil {
13021302 a.OnToolResult(tc.ID, res)
13031303 }
1304+ if update, ok := res.Details.(PlanUpdate); ok && !res.IsError {
1305+ sink(EvPlanUpdate{Update: update})
1306+ }
13041307 sink(EvToolResult{ID: tc.ID, Result: res})
13051308 }
13061309
@@ -1356,6 +1359,12 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
13561359 }
13571360 }
13581361 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+ }
13591368 args = modified
13601369 }
13611370 }
M packages/core/events.go +28 -0
@@ -171,6 +171,34 @@ type EvToolProgress struct {
171171
172172 func (EvToolProgress) Type() string { return "tool_progress" }
173173
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+
174202 type EvToolResult struct {
175203 ID string
176204 Result ToolResult
M packages/core/intercept_test.go +55 -3
@@ -21,6 +21,10 @@ type resultTool struct {
2121 panic bool
2222 }
2323
24+type immutableResultTool struct{ resultTool }
25+
26+func (*immutableResultTool) AllowArgumentRewrite() bool { return false }
27+
2428 func (t *resultTool) Name() string { return "result" }
2529 func (t *resultTool) Description() string { return "result" }
2630 func (t *resultTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
@@ -80,6 +84,37 @@ func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) {
8084 }
8185 }
8286
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+
83118 func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) {
84119 agent := NewAgent(nil, "test", "", Registry{
85120 "result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "done"}}}},
@@ -98,9 +133,26 @@ func TestToolResultCommitErrorUsesSafeMessage(t *testing.T) {
98133 }
99134 }
100135
101-// TestBeforeToolExecuteModifiesArgs verifies that a non-nil
102-// modifiedArgs returned from BeforeToolExecute is what the tool
103-// actually sees.
136+func TestBeforeToolExecuteRejectsRewriteForImmutableTool(t *testing.T) {
137+ tool := &immutableResultTool{}
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.
104156 func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
105157 rec := &recordingTool{}
106158 reg := Registry{"echo": rec}
M packages/core/tool.go +6 -0
@@ -32,6 +32,12 @@ type ToolPreviewer interface {
3232 Preview(ctx context.Context, args json.RawMessage) (ToolResult, error)
3333 }
3434
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+
3541 // ToolResult is the outcome of Tool.Execute.
3642 type ToolResult struct {
3743 // Content is sent back to the LLM (text and/or images).
M packages/tui/input.go +0 -5
@@ -41,7 +41,6 @@ const (
4141 KeyCtrlU
4242 KeyCtrlK
4343 KeyCtrlA
44- KeyCtrlB
4544 KeyCtrlE
4645 KeyCtrlW
4746 KeyCtrlO
@@ -180,8 +179,6 @@ func (r *Reader) Read() (Key, error) {
180179 return Key{Kind: KeyCtrlK}, nil
181180 case b == 0x01:
182181 return Key{Kind: KeyCtrlA}, nil
183- case b == 0x02:
184- return Key{Kind: KeyCtrlB}, nil
185182 case b == 0x05:
186183 return Key{Kind: KeyCtrlE}, nil
187184 case b == 0x17:
@@ -491,8 +488,6 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
491488 return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true
492489 case 'a', 'A':
493490 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
496491 case 'e', 'E':
497492 return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true
498493 case 'w', 'W':
M packages/tui/input_test.go +0 -12
@@ -62,18 +62,6 @@ func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
6262 }
6363 }
6464
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-
7765 func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) {
7866 k := readKey(t, "\x1b[118;5u")
7967 if k.Kind != KeyPasteClipboard || !k.Ctrl {
M packages/tui/render.go +24 -48
@@ -46,24 +46,15 @@ type Renderer struct {
4646 logLines []string
4747 // Raw logical rows let DrawLog reject an unchanged frame before
4848 // truncating and repainting every transcript row.
49- logRawChat []string
50- logRawBottom []string
51- logRawCols int
52- logRawRows int
53- logRawContentCols int
54- logRawBackground string
55- logRawHasThemeBG bool
56- logViewportTop int
57- logHardwareRow int
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
49+ logRawChat []string
50+ logRawBottom []string
51+ logRawCols int
52+ logRawRows int
53+ logRawBackground string
54+ logRawHasThemeBG bool
55+ logViewportTop int
56+ logHardwareRow int
57+ logInit bool
6758
6859 // keepScrollback is true when we must NOT emit \x1b[3J
6960 // (erase-in-display 3, "clear scrollback").
@@ -142,9 +133,6 @@ func (r *Renderer) Resize(cols, rows int) {
142133 r.logViewportTop = 0
143134 r.logHardwareRow = 0
144135 r.logInit = false
145- r.rightBarActive = false
146- r.rightBarLines = nil
147- r.rightBarMainCols = 0
148136 if r.out != nil {
149137 if r.keepScrollback {
150138 // A resize is a discrete user action, like Ctrl+L: the
@@ -181,9 +169,6 @@ func (r *Renderer) Clear() {
181169 r.logViewportTop = 0
182170 r.logHardwareRow = 0
183171 r.logInit = false
184- r.rightBarActive = false
185- r.rightBarLines = nil
186- r.rightBarMainCols = 0
187172 if r.keepScrollback {
188173 // On VS Code's xterm.js the transcript is taller than the
189174 // viewport, so an in-place clear (home + erase-to-end) only
@@ -599,34 +584,27 @@ func (r *Renderer) DrawFloating(pane FloatingPaneFrame, cursorRow, cursorCol int
599584 //
600585 // cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
601586 func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
602- if r.rightBarActive {
603- r.clearRightBarOverlay()
604- }
605- r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols)
587+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol)
606588 }
607589
608-// drawLog is DrawLog's owning flow implementation. contentCols may reserve
609-// terminal columns for a separately composited viewport overlay.
610-func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
590+// drawLog is DrawLog's owning flow implementation.
591+func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol int) bool {
611592 if r.cols == 0 || r.rows == 0 {
612593 return false
613594 }
614- if contentCols <= 0 || contentCols > r.cols {
615- contentCols = r.cols
616- }
617595 if len(bottom) == 0 {
618596 bottom = []string{""}
619597 }
620- if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) {
598+ if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol) {
621599 return false
622600 }
623601 chatFrame := make([]string, len(chat))
624602 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)
626604 }
627605 bottomFrame := make([]string, len(bottom))
628606 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)
630608 }
631609
632610 // 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
638616 lines = append(lines, chatFrame...)
639617 lines = append(lines, bottomFrame...)
640618 for range bottomMarginRows {
641- lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
619+ lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
642620 }
643621 // In main-screen flow mode zut normally emits only its logical
644622 // 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
648626 // default transparent case.
649627 if r.theme.Background != nil {
650628 for len(lines) < r.rows {
651- lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
629+ lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
652630 }
653631 }
654632 if len(lines) == 0 {
@@ -670,8 +648,7 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co
670648 // that never blinks, because we keep "showing" it before the
671649 // terminal can blink it off. Bailing out here lets the OS run
672650 // its blink cycle.
673- if r.logInit && r.logRawContentCols == contentCols &&
674- cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
651+ if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
675652 return false
676653 }
677654
@@ -770,8 +747,8 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co
770747 if physicalCursorCol < 0 {
771748 physicalCursorCol = 0
772749 }
773- if physicalCursorCol >= contentCols {
774- physicalCursorCol = contentCols - 1
750+ if physicalCursorCol >= r.cols {
751+ physicalCursorCol = r.cols - 1
775752 }
776753 if physicalCursorCol > 0 {
777754 w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C")
@@ -1052,7 +1029,6 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co
10521029 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
10531030 r.logRawCols = r.cols
10541031 r.logRawRows = r.rows
1055- r.logRawContentCols = contentCols
10561032 r.logRawBackground = r.backgroundStyle
10571033 r.logRawHasThemeBG = r.theme.Background != nil
10581034 r.cursorRow = cursorBottomRow
@@ -1060,14 +1036,14 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co
10601036 return true
10611037 }
10621038
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 {
10641040 return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
1065- r.logRawContentUnchanged(chat, bottom, contentCols)
1041+ r.logRawContentUnchanged(chat, bottom)
10661042 }
10671043
1068-func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool {
1044+func (r *Renderer) logRawContentUnchanged(chat, bottom []string) bool {
10691045 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 &&
10711047 r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) &&
10721048 sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom)
10731049 }
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 @@
11 package tui
22
33 import (
4+ "bytes"
45 "encoding/json"
56 "fmt"
7+ "io"
68 "os"
79 "regexp"
810 "strconv"
@@ -128,6 +130,8 @@ type View struct {
128130 // the box top edge without having to look back at the assistant
129131 // message that originated the call. Rebuilt on each Build().
130132 toolCallLabels map[string]string
133+ toolCallNames map[string]string
134+ toolCallArgs map[string]json.RawMessage
131135 Streaming string // current assistant text delta
132136 StreamingActive bool
133137 ToolCalls []ToolCallView // tool calls in flight or completed
@@ -275,6 +279,8 @@ func (v *View) CloneForRender() *View {
275279 clone.toolPaths = cloneStringMap(v.toolPaths)
276280 clone.toolStartLines = cloneIntMap(v.toolStartLines)
277281 clone.toolCallLabels = cloneStringMap(v.toolCallLabels)
282+ clone.toolCallNames = cloneStringMap(v.toolCallNames)
283+ clone.toolCallArgs = cloneRawMessageMap(v.toolCallArgs)
278284 clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh)
279285 if v.renderCache != nil {
280286 clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache))
@@ -303,6 +309,17 @@ func cloneStringMap(src map[string]string) map[string]string {
303309 return dst
304310 }
305311
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+
306323 func cloneIntMap(src map[string]int) map[string]int {
307324 if src == nil {
308325 return nil
@@ -329,6 +346,8 @@ func (v *View) AdoptRenderCacheFrom(snapshot *View) {
329346 v.toolPaths = snapshot.toolPaths
330347 v.toolStartLines = snapshot.toolStartLines
331348 v.toolCallLabels = snapshot.toolCallLabels
349+ v.toolCallNames = snapshot.toolCallNames
350+ v.toolCallArgs = snapshot.toolCallArgs
332351 v.toolPathRevision = snapshot.toolPathRevision
333352 }
334353
@@ -698,6 +717,8 @@ func (v *View) refreshToolPaths() {
698717 v.toolPaths = map[string]string{}
699718 v.toolStartLines = map[string]int{}
700719 v.toolCallLabels = map[string]string{}
720+ v.toolCallNames = map[string]string{}
721+ v.toolCallArgs = map[string]json.RawMessage{}
701722 for _, m := range v.Messages {
702723 for _, c := range m.Content {
703724 if tc, ok := c.(provider.ToolCallBlock); ok {
@@ -708,6 +729,8 @@ func (v *View) refreshToolPaths() {
708729 v.toolStartLines[tc.ID] = off
709730 }
710731 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
711734 }
712735 }
713736 }
@@ -922,6 +945,103 @@ func fnv64aWrite(h uint64, p []byte) uint64 {
922945 return h
923946 }
924947
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+
9251045 func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string {
9261046 var lines []string
9271047
@@ -1032,6 +1152,12 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
10321152 case provider.RoleTool:
10331153 for _, c := range m.Content {
10341154 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+ }
10351161 color := v.Theme.ToolOut
10361162 if tr.IsError {
10371163 color = v.Theme.Error
@@ -1237,6 +1363,12 @@ func hashThemeColor(h uint64, c TerminalColor) uint64 {
12371363 }
12381364
12391365 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+
12401372 var lines []string
12411373
12421374 // 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close