feat(tui): add right sidebar toggle
- commit
- 545e6c199b80d34828e0763fc310f4b85a4c7652
- author
- brice · · 2026-08-05 13:32 UTC
- parent
- 4ed7779
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +765 -174
M README.md +1 -0
| @@ -888,6 +888,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 888 | 888 | | `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. | | |
| 889 | 889 | | `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. | | |
| 890 | 890 | | `ctrl+d` | Exit on empty input. | | |
| 891 | + | | `ctrl+b` | Toggle the right sidebar; hidden/narrow right-bar widgets fall back above the input within a bounded area and show an ellipsis when truncated. | | |
| 891 | 892 | | `ctrl+l` | Redraw the screen. | | |
| 892 | 893 | | `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. | | |
| 893 | 894 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). | | |
| @@ -888,6 +888,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 888 | | `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. | | 888 | | `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. | | ||
| 889 | | `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. | | 889 | | `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. | | ||
| 890 | | `ctrl+d` | Exit on empty input. | | 890 | | `ctrl+d` | Exit on empty input. | | ||
| 891 | + | | `ctrl+b` | Toggle the right sidebar; hidden/narrow right-bar widgets fall back above the input within a bounded area and show an ellipsis when truncated. | | |||
| 891 | | `ctrl+l` | Redraw the screen. | | 892 | | `ctrl+l` | Redraw the screen. | | ||
| 892 | | `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. | | 893 | | `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. | | ||
| 893 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). | | 894 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). | | ||
M docs/extensions.md +13 -5
| @@ -428,9 +428,12 @@ hint with these interactive-zot values: | |||
| 428 | 428 | ||
| 429 | 429 | The right bar is host-owned: it bounds width and height, orders widgets by | |
| 430 | 430 | extension and ID, and truncates content that does not fit. On narrow terminals | |
| 431 | - | it falls back automatically to `above_input`. Empty or unknown positions retain | |
| 432 | - | the historical `above_input` behavior so older extensions remain compatible. | |
| 433 | - | Use `open_panel` when a widget needs interaction or navigation. | |
| 431 | + | it falls back automatically to `above_input`. In interactive zot, `Ctrl+B` | |
| 432 | + | toggles the sidebar; while hidden, right-bar widgets use the same bounded | |
| 433 | + | `above_input` fallback and show a truncation marker when the shared chrome | |
| 434 | + | budget is exceeded. Empty or unknown positions retain the historical | |
| 435 | + | `above_input` behavior so older extensions remain compatible. Use `open_panel` | |
| 436 | + | when a widget needs interaction or navigation. | |
| 434 | 437 | ||
| 435 | 438 | ```json | |
| 436 | 439 | {"type":"widget","id":"plan","position":"right_bar", | |
| @@ -723,8 +726,13 @@ e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines) | |||
| 723 | 726 | e.ClearWidget("plan") | |
| 724 | 727 | ``` | |
| 725 | 728 | ||
| 726 | - | `right_bar` widgets are display-only in the first version. Open an extension | |
| 727 | - | panel for editing or keyboard navigation. | |
| 729 | + | `right_bar` widgets are display-only in the first version. The host renders | |
| 730 | + | one muted separator beside the main pane, leaves one blank row below each | |
| 731 | + | widget title, and does not add a second widget frame or top/bottom rules. In | |
| 732 | + | interactive zot, `Ctrl+B` toggles the rail; hidden or narrow right-bar widgets | |
| 733 | + | use a bounded `above_input` fallback with a truncation marker. Open an | |
| 734 | + | extension panel for editing or keyboard navigation; clearing the last | |
| 735 | + | right-bar widget removes the rail. | |
| 728 | 736 | ||
| 729 | 737 | The SDK has four interceptor hooks, all optional: | |
| 730 | 738 | ||
| @@ -428,9 +428,12 @@ hint with these interactive-zot values: | |||||
| 428 | 428 | ||||
| 429 | The right bar is host-owned: it bounds width and height, orders widgets by | 429 | The right bar is host-owned: it bounds width and height, orders widgets by | ||
| 430 | extension and ID, and truncates content that does not fit. On narrow terminals | 430 | extension and ID, and truncates content that does not fit. On narrow terminals | ||
| 431 | - | it falls back automatically to `above_input`. Empty or unknown positions retain | 431 | + | it falls back automatically to `above_input`. In interactive zot, `Ctrl+B` |
| 432 | - | the historical `above_input` behavior so older extensions remain compatible. | 432 | + | toggles the sidebar; while hidden, right-bar widgets use the same bounded |
| 433 | - | Use `open_panel` when a widget needs interaction or navigation. | 433 | + | `above_input` fallback and show a truncation marker when the shared chrome |
| 434 | + | budget is exceeded. Empty or unknown positions retain the historical | |||
| 435 | + | `above_input` behavior so older extensions remain compatible. Use `open_panel` | |||
| 436 | + | when a widget needs interaction or navigation. | |||
| 434 | 437 | ||||
| 435 | ```json | 438 | ```json | ||
| 436 | {"type":"widget","id":"plan","position":"right_bar", | 439 | {"type":"widget","id":"plan","position":"right_bar", | ||
| @@ -723,8 +726,13 @@ e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines) | |||||
| 723 | e.ClearWidget("plan") | 726 | e.ClearWidget("plan") | ||
| 724 | ``` | 727 | ``` | ||
| 725 | 728 | ||||
| 726 | - | `right_bar` widgets are display-only in the first version. Open an extension | 729 | + | `right_bar` widgets are display-only in the first version. The host renders |
| 727 | - | panel for editing or keyboard navigation. | 730 | + | one muted separator beside the main pane, leaves one blank row below each |
| 731 | + | widget title, and does not add a second widget frame or top/bottom rules. In | |||
| 732 | + | interactive zot, `Ctrl+B` toggles the rail; hidden or narrow right-bar widgets | |||
| 733 | + | use a bounded `above_input` fallback with a truncation marker. Open an | |||
| 734 | + | extension panel for editing or keyboard navigation; clearing the last | |||
| 735 | + | right-bar widget removes the rail. | |||
| 728 | 736 | ||||
| 729 | The SDK has four interceptor hooks, all optional: | 737 | The SDK has four interceptor hooks, all optional: | ||
| 730 | 738 | ||||
M examples/extensions/tasked-phases/README.md +21 -3
| @@ -18,6 +18,13 @@ set_current_phase set_task_checked set_phase_checked | |||
| 18 | 18 | clear | |
| 19 | 19 | ``` | |
| 20 | 20 | ||
| 21 | + | Successful mutations return one concise line such as `Checked task task-4 | |
| 22 | + | (p 1/2 | t 3/4)`. `get_status` and `replace_plan` remain detailed when the | |
| 23 | + | full state is useful. `set_task_checked` changes one task; `set_phase_checked` | |
| 24 | + | intentionally changes every task in the selected phase. Repeated individual | |
| 25 | + | checks in one model turn are sequential tool calls, not an automatic bulk | |
| 26 | + | update. | |
| 27 | + | ||
| 21 | 28 | ## Build and run | |
| 22 | 29 | ||
| 23 | 30 | From the zot repository: | |
| @@ -63,7 +70,18 @@ as it is completed, and advance the current phase when work moves forward. | |||
| 63 | 70 | Completed phases and future phases are not repeated in every provider request; | |
| 64 | 71 | use `get_status` or `/phases` when the full checklist is needed. | |
| 65 | 72 | ||
| 66 | - | When a plan is active, the extension publishes its compact checklist through the | |
| 67 | - | host's generic `right_bar` widget position. zot keeps it beside the transcript on | |
| 68 | - | wide terminals and automatically falls back to the normal above-input widget on | |
| 73 | + | When a plan is stored, the extension automatically publishes a compact summary | |
| 74 | + | through the host's generic `right_bar` widget position. Both the right-bar title | |
| 75 | + | and the status bar use the short form `p done/total | t done/total`; active plans | |
| 76 | + | also show their phase and task checklist rows below the title, with one blank | |
| 77 | + | row after the title. Phase rows use one leading space and bold phase names; | |
| 78 | + | task rows use two leading spaces, and wrapped task text hangs under its task | |
| 79 | + | label. The summary and rows are refreshed after every plan mutation and session | |
| 80 | + | restore. Checklist markers use `[x]`, `[ ]`, and `[>]`; the host dims rows | |
| 81 | + | outside the current phase. | |
| 82 | + | Once every phase and task is complete, the persistent status and right-bar widget | |
| 83 | + | are cleared. Use `get_status` or `/phases` for the complete checklist and current | |
| 84 | + | task details. Spec text is deliberately excluded from persistent chrome. zot | |
| 85 | + | keeps the summary beside the transcript on wide | |
| 86 | + | terminals and automatically falls back to the normal above-input widget on | |
| 69 | 87 | narrow terminals. The extension does not implement any terminal layout code. | |
| @@ -18,6 +18,13 @@ set_current_phase set_task_checked set_phase_checked | |||||
| 18 | clear | 18 | clear | ||
| 19 | ``` | 19 | ``` | ||
| 20 | 20 | ||||
| 21 | + | Successful mutations return one concise line such as `Checked task task-4 | |||
| 22 | + | (p 1/2 | t 3/4)`. `get_status` and `replace_plan` remain detailed when the | |||
| 23 | + | full state is useful. `set_task_checked` changes one task; `set_phase_checked` | |||
| 24 | + | intentionally changes every task in the selected phase. Repeated individual | |||
| 25 | + | checks in one model turn are sequential tool calls, not an automatic bulk | |||
| 26 | + | update. | |||
| 27 | + | ||||
| 21 | ## Build and run | 28 | ## Build and run | ||
| 22 | 29 | ||||
| 23 | From the zot repository: | 30 | From the zot repository: | ||
| @@ -63,7 +70,18 @@ as it is completed, and advance the current phase when work moves forward. | |||||
| 63 | Completed phases and future phases are not repeated in every provider request; | 70 | Completed phases and future phases are not repeated in every provider request; | ||
| 64 | use `get_status` or `/phases` when the full checklist is needed. | 71 | use `get_status` or `/phases` when the full checklist is needed. | ||
| 65 | 72 | ||||
| 66 | - | When a plan is active, the extension publishes its compact checklist through the | 73 | + | When a plan is stored, the extension automatically publishes a compact summary |
| 67 | - | host's generic `right_bar` widget position. zot keeps it beside the transcript on | 74 | + | through the host's generic `right_bar` widget position. Both the right-bar title |
| 68 | - | wide terminals and automatically falls back to the normal above-input widget on | 75 | + | and the status bar use the short form `p done/total | t done/total`; active plans |
| 76 | + | also show their phase and task checklist rows below the title, with one blank | |||
| 77 | + | row after the title. Phase rows use one leading space and bold phase names; | |||
| 78 | + | task rows use two leading spaces, and wrapped task text hangs under its task | |||
| 79 | + | label. The summary and rows are refreshed after every plan mutation and session | |||
| 80 | + | restore. Checklist markers use `[x]`, `[ ]`, and `[>]`; the host dims rows | |||
| 81 | + | outside the current phase. | |||
| 82 | + | Once every phase and task is complete, the persistent status and right-bar widget | |||
| 83 | + | are cleared. Use `get_status` or `/phases` for the complete checklist and current | |||
| 84 | + | task details. Spec text is deliberately excluded from persistent chrome. zot | |||
| 85 | + | keeps the summary beside the transcript on wide | |||
| 86 | + | terminals and automatically falls back to the normal above-input widget on | |||
| 69 | narrow terminals. The extension does not implement any terminal layout code. | 87 | narrow terminals. The extension does not implement any terminal layout code. | ||
M examples/extensions/tasked-phases/main.go +117 -25
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "encoding/json" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | - | "strings" | |
| 8 | 7 | "sync" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/patriceckhart/zot/packages/agent/ext" | |
| @@ -88,10 +87,14 @@ func main() { | |||
| 88 | 87 | } | |
| 89 | 88 | if err := a.store.load(dataDir, host.CWD); err != nil { | |
| 90 | 89 | e.Logf("load project state: %v", err) | |
| 90 | + | a.publishUnavailableChrome() | |
| 91 | 91 | return | |
| 92 | 92 | } | |
| 93 | 93 | if state, err := a.store.snapshot(); err == nil { | |
| 94 | 94 | a.publishChrome(state) | |
| 95 | + | } else { | |
| 96 | + | e.Logf("snapshot project state: %v", err) | |
| 97 | + | a.publishUnavailableChrome() | |
| 95 | 98 | } | |
| 96 | 99 | }) | |
| 97 | 100 | restoreSession := func(event ext.Event) { | |
| @@ -103,6 +106,9 @@ func main() { | |||
| 103 | 106 | if state, err := a.store.snapshot(); err == nil { | |
| 104 | 107 | a.publishChrome(state) | |
| 105 | 108 | a.renderPanelIfOpen(state) | |
| 109 | + | } else { | |
| 110 | + | e.Logf("restore session snapshot: %v", err) | |
| 111 | + | a.publishUnavailableChrome() | |
| 106 | 112 | } | |
| 107 | 113 | } | |
| 108 | 114 | e.On("session_opened", restoreSession) | |
| @@ -132,6 +138,7 @@ func main() { | |||
| 132 | 138 | func (a *app) handleCommand(_ string) ext.Response { | |
| 133 | 139 | state, err := a.store.snapshot() | |
| 134 | 140 | if err != nil { | |
| 141 | + | a.publishUnavailableChrome() | |
| 135 | 142 | return ext.Errorf("load tasked phases: %v", err) | |
| 136 | 143 | } | |
| 137 | 144 | a.publishChrome(state) | |
| @@ -177,6 +184,7 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult { | |||
| 177 | 184 | if params.Action == actionGetStatus { | |
| 178 | 185 | state, err := a.store.snapshot() | |
| 179 | 186 | if err != nil { | |
| 187 | + | a.publishUnavailableChrome() | |
| 180 | 188 | return toolResult(params.Action, PlanState{}, "", err) | |
| 181 | 189 | } | |
| 182 | 190 | a.publishChrome(state) | |
| @@ -188,11 +196,13 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult { | |||
| 188 | 196 | return applyAction(current, params) | |
| 189 | 197 | }) | |
| 190 | 198 | // A zero state means transact could not obtain a loaded snapshot. Do not | |
| 191 | - | // replace persistent chrome with an invented empty plan. For action and | |
| 192 | - | // persistence errors, transact returns the unchanged usable state, which | |
| 193 | - | // should remain visible to help the user recover. | |
| 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. | |
| 194 | 202 | if err == nil || state.Version != 0 { | |
| 195 | 203 | a.publishChrome(state) | |
| 204 | + | } else { | |
| 205 | + | a.publishUnavailableChrome() | |
| 196 | 206 | } | |
| 197 | 207 | if err == nil { | |
| 198 | 208 | a.renderPanelIfOpen(state) | |
| @@ -201,8 +211,7 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult { | |||
| 201 | 211 | } | |
| 202 | 212 | ||
| 203 | 213 | func (a *app) publishUnavailableChrome() { | |
| 204 | - | a.ext.SetStatus("progress", "plan unavailable") | |
| 205 | - | a.ext.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", []string{"Plan state could not be restored for this session."}) | |
| 214 | + | clearChrome(a.ext) | |
| 206 | 215 | a.panelMu.Lock() | |
| 207 | 216 | open := a.panelOpen | |
| 208 | 217 | a.panelMu.Unlock() | |
| @@ -212,30 +221,109 @@ func (a *app) publishUnavailableChrome() { | |||
| 212 | 221 | } | |
| 213 | 222 | ||
| 214 | 223 | type chromeContent struct { | |
| 215 | - | status string | |
| 216 | - | lines []string | |
| 224 | + | summary string | |
| 225 | + | lines []string | |
| 217 | 226 | } | |
| 218 | 227 | ||
| 228 | + | const ( | |
| 229 | + | chromePhaseMaxChars = 32 | |
| 230 | + | chromeTaskMaxChars = 64 | |
| 231 | + | ) | |
| 232 | + | ||
| 219 | 233 | func buildChrome(state PlanState) (chromeContent, bool) { | |
| 220 | - | // An empty state is the normal idle state, not useful persistent chrome. | |
| 221 | - | if !hasStoredPlan(state) { | |
| 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) { | |
| 222 | 238 | return chromeContent{}, false | |
| 223 | 239 | } | |
| 224 | - | ||
| 225 | - | status := "" | |
| 226 | - | if isPlanClosed(state) { | |
| 227 | - | status = "closed: " + state.ClosedSummary | |
| 228 | - | } else if done, total := getPlanProgress(state); total > 0 { | |
| 229 | - | status = fmt.Sprintf("%d/%d tasks checked", done, total) | |
| 230 | - | } else { | |
| 231 | - | status = "no active plan" | |
| 232 | - | } | |
| 233 | 240 | return chromeContent{ | |
| 234 | - | status: status, | |
| 235 | - | lines: strings.Split(buildCompactSummary(state), "\n"), | |
| 241 | + | summary: buildChromeSummary(state), | |
| 242 | + | lines: buildChecklistLines(state), | |
| 236 | 243 | }, true | |
| 237 | 244 | } | |
| 238 | 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 | + | ||
| 239 | 327 | type chromeHost interface { | |
| 240 | 328 | SetStatus(key, text string) | |
| 241 | 329 | ClearStatus(key string) | |
| @@ -243,15 +331,19 @@ type chromeHost interface { | |||
| 243 | 331 | ClearWidget(id string) | |
| 244 | 332 | } | |
| 245 | 333 | ||
| 334 | + | func clearChrome(host chromeHost) { | |
| 335 | + | host.ClearStatus("progress") | |
| 336 | + | host.ClearWidget("plan") | |
| 337 | + | } | |
| 338 | + | ||
| 246 | 339 | func applyChrome(host chromeHost, state PlanState) { | |
| 247 | 340 | chrome, ok := buildChrome(state) | |
| 248 | 341 | if !ok { | |
| 249 | - | host.ClearStatus("progress") | |
| 250 | - | host.ClearWidget("plan") | |
| 342 | + | clearChrome(host) | |
| 251 | 343 | return | |
| 252 | 344 | } | |
| 253 | - | host.SetStatus("progress", chrome.status) | |
| 254 | - | host.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", chrome.lines) | |
| 345 | + | host.SetStatus("progress", chrome.summary) | |
| 346 | + | host.SetWidget("plan", ext.WidgetPositionRightBar, chrome.summary, chrome.lines) | |
| 255 | 347 | } | |
| 256 | 348 | ||
| 257 | 349 | func (a *app) publishChrome(state PlanState) { | |
| @@ -4,7 +4,6 @@ import ( | |||||
| 4 | "encoding/json" | 4 | "encoding/json" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | - | "strings" | |||
| 8 | "sync" | 7 | "sync" | ||
| 9 | 8 | ||||
| 10 | "github.com/patriceckhart/zot/packages/agent/ext" | 9 | "github.com/patriceckhart/zot/packages/agent/ext" | ||
| @@ -88,10 +87,14 @@ func main() { | |||||
| 88 | } | 87 | } | ||
| 89 | if err := a.store.load(dataDir, host.CWD); err != nil { | 88 | if err := a.store.load(dataDir, host.CWD); err != nil { | ||
| 90 | e.Logf("load project state: %v", err) | 89 | e.Logf("load project state: %v", err) | ||
| 90 | + | a.publishUnavailableChrome() | |||
| 91 | return | 91 | return | ||
| 92 | } | 92 | } | ||
| 93 | if state, err := a.store.snapshot(); err == nil { | 93 | if state, err := a.store.snapshot(); err == nil { | ||
| 94 | a.publishChrome(state) | 94 | a.publishChrome(state) | ||
| 95 | + | } else { | |||
| 96 | + | e.Logf("snapshot project state: %v", err) | |||
| 97 | + | a.publishUnavailableChrome() | |||
| 95 | } | 98 | } | ||
| 96 | }) | 99 | }) | ||
| 97 | restoreSession := func(event ext.Event) { | 100 | restoreSession := func(event ext.Event) { | ||
| @@ -103,6 +106,9 @@ func main() { | |||||
| 103 | if state, err := a.store.snapshot(); err == nil { | 106 | if state, err := a.store.snapshot(); err == nil { | ||
| 104 | a.publishChrome(state) | 107 | a.publishChrome(state) | ||
| 105 | a.renderPanelIfOpen(state) | 108 | a.renderPanelIfOpen(state) | ||
| 109 | + | } else { | |||
| 110 | + | e.Logf("restore session snapshot: %v", err) | |||
| 111 | + | a.publishUnavailableChrome() | |||
| 106 | } | 112 | } | ||
| 107 | } | 113 | } | ||
| 108 | e.On("session_opened", restoreSession) | 114 | e.On("session_opened", restoreSession) | ||
| @@ -132,6 +138,7 @@ func main() { | |||||
| 132 | func (a *app) handleCommand(_ string) ext.Response { | 138 | func (a *app) handleCommand(_ string) ext.Response { | ||
| 133 | state, err := a.store.snapshot() | 139 | state, err := a.store.snapshot() | ||
| 134 | if err != nil { | 140 | if err != nil { | ||
| 141 | + | a.publishUnavailableChrome() | |||
| 135 | return ext.Errorf("load tasked phases: %v", err) | 142 | return ext.Errorf("load tasked phases: %v", err) | ||
| 136 | } | 143 | } | ||
| 137 | a.publishChrome(state) | 144 | a.publishChrome(state) | ||
| @@ -177,6 +184,7 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult { | |||||
| 177 | if params.Action == actionGetStatus { | 184 | if params.Action == actionGetStatus { | ||
| 178 | state, err := a.store.snapshot() | 185 | state, err := a.store.snapshot() | ||
| 179 | if err != nil { | 186 | if err != nil { | ||
| 187 | + | a.publishUnavailableChrome() | |||
| 180 | return toolResult(params.Action, PlanState{}, "", err) | 188 | return toolResult(params.Action, PlanState{}, "", err) | ||
| 181 | } | 189 | } | ||
| 182 | a.publishChrome(state) | 190 | a.publishChrome(state) | ||
| @@ -188,11 +196,13 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult { | |||||
| 188 | return applyAction(current, params) | 196 | return applyAction(current, params) | ||
| 189 | }) | 197 | }) | ||
| 190 | // A zero state means transact could not obtain a loaded snapshot. Do not | 198 | // A zero state means transact could not obtain a loaded snapshot. Do not | ||
| 191 | - | // replace persistent chrome with an invented empty plan. For action and | 199 | + | // replace persistent chrome with an invented empty plan; clear it instead. |
| 192 | - | // persistence errors, transact returns the unchanged usable state, which | 200 | + | // For action and persistence errors, transact returns the unchanged usable |
| 193 | - | // should remain visible to help the user recover. | 201 | + | // state, which should remain visible to help the user recover. |
| 194 | if err == nil || state.Version != 0 { | 202 | if err == nil || state.Version != 0 { | ||
| 195 | a.publishChrome(state) | 203 | a.publishChrome(state) | ||
| 204 | + | } else { | |||
| 205 | + | a.publishUnavailableChrome() | |||
| 196 | } | 206 | } | ||
| 197 | if err == nil { | 207 | if err == nil { | ||
| 198 | a.renderPanelIfOpen(state) | 208 | a.renderPanelIfOpen(state) | ||
| @@ -201,8 +211,7 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult { | |||||
| 201 | } | 211 | } | ||
| 202 | 212 | ||||
| 203 | func (a *app) publishUnavailableChrome() { | 213 | func (a *app) publishUnavailableChrome() { | ||
| 204 | - | a.ext.SetStatus("progress", "plan unavailable") | 214 | + | clearChrome(a.ext) |
| 205 | - | a.ext.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", []string{"Plan state could not be restored for this session."}) | |||
| 206 | a.panelMu.Lock() | 215 | a.panelMu.Lock() | ||
| 207 | open := a.panelOpen | 216 | open := a.panelOpen | ||
| 208 | a.panelMu.Unlock() | 217 | a.panelMu.Unlock() | ||
| @@ -212,30 +221,109 @@ func (a *app) publishUnavailableChrome() { | |||||
| 212 | } | 221 | } | ||
| 213 | 222 | ||||
| 214 | type chromeContent struct { | 223 | type chromeContent struct { | ||
| 215 | - | status string | 224 | + | summary string |
| 216 | - | lines []string | 225 | + | lines []string |
| 217 | } | 226 | } | ||
| 218 | 227 | ||||
| 228 | + | const ( | |||
| 229 | + | chromePhaseMaxChars = 32 | |||
| 230 | + | chromeTaskMaxChars = 64 | |||
| 231 | + | ) | |||
| 232 | + | ||||
| 219 | func buildChrome(state PlanState) (chromeContent, bool) { | 233 | func buildChrome(state PlanState) (chromeContent, bool) { | ||
| 220 | - | // An empty state is the normal idle state, not useful persistent chrome. | 234 | + | // A spec-only state is still a plan in progress. Show its compact summary |
| 221 | - | if !hasStoredPlan(state) { | 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) { | |||
| 222 | return chromeContent{}, false | 238 | return chromeContent{}, false | ||
| 223 | } | 239 | } | ||
| 224 | - | ||||
| 225 | - | status := "" | |||
| 226 | - | if isPlanClosed(state) { | |||
| 227 | - | status = "closed: " + state.ClosedSummary | |||
| 228 | - | } else if done, total := getPlanProgress(state); total > 0 { | |||
| 229 | - | status = fmt.Sprintf("%d/%d tasks checked", done, total) | |||
| 230 | - | } else { | |||
| 231 | - | status = "no active plan" | |||
| 232 | - | } | |||
| 233 | return chromeContent{ | 240 | return chromeContent{ | ||
| 234 | - | status: status, | 241 | + | summary: buildChromeSummary(state), |
| 235 | - | lines: strings.Split(buildCompactSummary(state), "\n"), | 242 | + | lines: buildChecklistLines(state), |
| 236 | }, true | 243 | }, true | ||
| 237 | } | 244 | } | ||
| 238 | 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 | + | ||||
| 239 | type chromeHost interface { | 327 | type chromeHost interface { | ||
| 240 | SetStatus(key, text string) | 328 | SetStatus(key, text string) | ||
| 241 | ClearStatus(key string) | 329 | ClearStatus(key string) | ||
| @@ -243,15 +331,19 @@ type chromeHost interface { | |||||
| 243 | ClearWidget(id string) | 331 | ClearWidget(id string) | ||
| 244 | } | 332 | } | ||
| 245 | 333 | ||||
| 334 | + | func clearChrome(host chromeHost) { | |||
| 335 | + | host.ClearStatus("progress") | |||
| 336 | + | host.ClearWidget("plan") | |||
| 337 | + | } | |||
| 338 | + | ||||
| 246 | func applyChrome(host chromeHost, state PlanState) { | 339 | func applyChrome(host chromeHost, state PlanState) { | ||
| 247 | chrome, ok := buildChrome(state) | 340 | chrome, ok := buildChrome(state) | ||
| 248 | if !ok { | 341 | if !ok { | ||
| 249 | - | host.ClearStatus("progress") | 342 | + | clearChrome(host) |
| 250 | - | host.ClearWidget("plan") | |||
| 251 | return | 343 | return | ||
| 252 | } | 344 | } | ||
| 253 | - | host.SetStatus("progress", chrome.status) | 345 | + | host.SetStatus("progress", chrome.summary) |
| 254 | - | host.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", chrome.lines) | 346 | + | host.SetWidget("plan", ext.WidgetPositionRightBar, chrome.summary, chrome.lines) |
| 255 | } | 347 | } | ||
| 256 | 348 | ||||
| 257 | func (a *app) publishChrome(state PlanState) { | 349 | func (a *app) publishChrome(state PlanState) { | ||
M examples/extensions/tasked-phases/main_test.go +147 -24
| @@ -12,68 +12,152 @@ func TestBuildChromeHidesEmptyPlan(t *testing.T) { | |||
| 12 | 12 | if visible { | |
| 13 | 13 | t.Fatal("empty plan should not publish persistent chrome") | |
| 14 | 14 | } | |
| 15 | - | if chrome.status != "" || len(chrome.lines) != 0 { | |
| 15 | + | if chrome.summary != "" || len(chrome.lines) != 0 { | |
| 16 | 16 | t.Fatalf("empty chrome = %#v, want zero content", chrome) | |
| 17 | 17 | } | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | - | func TestBuildChromeShowsStoredPlan(t *testing.T) { | |
| 20 | + | func TestBuildChromeUsesCompactSummaryWithoutSpecText(t *testing.T) { | |
| 21 | 21 | state := PlanState{ | |
| 22 | 22 | Version: 1, | |
| 23 | - | Spec: "Ship the compact view", | |
| 24 | - | Phases: []Phase{{ | |
| 25 | - | ID: "phase-1", | |
| 26 | - | Title: "Implement", | |
| 27 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Hide empty state"}}, | |
| 28 | - | }}, | |
| 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", | |
| 29 | 40 | } | |
| 30 | 41 | ||
| 31 | 42 | chrome, visible := buildChrome(state) | |
| 32 | 43 | if !visible { | |
| 33 | 44 | t.Fatal("stored plan should publish persistent chrome") | |
| 34 | 45 | } | |
| 35 | - | if chrome.status != "0/1 tasks checked" { | |
| 36 | - | t.Fatalf("chrome status = %q, want %q", chrome.status, "0/1 tasks checked") | |
| 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) | |
| 37 | 51 | } | |
| 38 | 52 | joined := strings.Join(chrome.lines, "\n") | |
| 39 | - | for _, want := range []string{"Spec: Ship the compact view", "Progress: 0/1 tasks checked", "Current phase: Implement"} { | |
| 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 | + | } { | |
| 40 | 59 | if !strings.Contains(joined, want) { | |
| 41 | - | t.Fatalf("chrome lines missing %q: %s", want, joined) | |
| 60 | + | t.Fatalf("checklist missing %q: %s", want, joined) | |
| 42 | 61 | } | |
| 43 | 62 | } | |
| 44 | 63 | } | |
| 45 | 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 | + | ||
| 46 | 101 | type chromeRecorder struct { | |
| 47 | 102 | statusSet int | |
| 48 | 103 | statusClear int | |
| 104 | + | statusTexts []string | |
| 49 | 105 | widgetSet int | |
| 50 | 106 | widgetClear int | |
| 51 | 107 | widgetPositions []string | |
| 108 | + | widgetTitles []string | |
| 109 | + | widgetLines [][]string | |
| 52 | 110 | } | |
| 53 | 111 | ||
| 54 | - | func (r *chromeRecorder) SetStatus(string, string) { r.statusSet++ } | |
| 55 | - | func (r *chromeRecorder) ClearStatus(string) { r.statusClear++ } | |
| 56 | - | func (r *chromeRecorder) SetWidget(_, position, _ string, _ []string) { | |
| 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) { | |
| 57 | 118 | r.widgetSet++ | |
| 58 | 119 | r.widgetPositions = append(r.widgetPositions, position) | |
| 120 | + | r.widgetTitles = append(r.widgetTitles, title) | |
| 121 | + | r.widgetLines = append(r.widgetLines, append([]string(nil), lines...)) | |
| 59 | 122 | } | |
| 60 | 123 | func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ } | |
| 61 | 124 | ||
| 62 | - | func TestApplyChromeUsesRightBar(t *testing.T) { | |
| 125 | + | func TestApplyChromePublishesCompactStatusAndTitle(t *testing.T) { | |
| 63 | 126 | recorder := &chromeRecorder{} | |
| 64 | - | applyChrome(recorder, PlanState{ | |
| 127 | + | state := PlanState{ | |
| 65 | 128 | Version: 1, | |
| 66 | - | Spec: "Keep the plan visible", | |
| 67 | - | Phases: []Phase{{ | |
| 68 | - | ID: "phase-1", | |
| 69 | - | Title: "Implement", | |
| 70 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Ship"}}, | |
| 71 | - | }}, | |
| 72 | - | }) | |
| 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) | |
| 73 | 144 | ||
| 74 | 145 | if recorder.widgetSet != 1 || len(recorder.widgetPositions) != 1 || recorder.widgetPositions[0] != ext.WidgetPositionRightBar { | |
| 75 | 146 | t.Fatalf("widget placement = %v, want %q", recorder.widgetPositions, ext.WidgetPositionRightBar) | |
| 76 | 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 | + | } | |
| 77 | 161 | } | |
| 78 | 162 | ||
| 79 | 163 | func TestApplyChromeClearsEmptyPlan(t *testing.T) { | |
| @@ -87,3 +171,42 @@ func TestApplyChromeClearsEmptyPlan(t *testing.T) { | |||
| 87 | 171 | t.Fatalf("empty plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet) | |
| 88 | 172 | } | |
| 89 | 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 | + | } | |
| @@ -12,68 +12,152 @@ func TestBuildChromeHidesEmptyPlan(t *testing.T) { | |||||
| 12 | if visible { | 12 | if visible { | ||
| 13 | t.Fatal("empty plan should not publish persistent chrome") | 13 | t.Fatal("empty plan should not publish persistent chrome") | ||
| 14 | } | 14 | } | ||
| 15 | - | if chrome.status != "" || len(chrome.lines) != 0 { | 15 | + | if chrome.summary != "" || len(chrome.lines) != 0 { |
| 16 | t.Fatalf("empty chrome = %#v, want zero content", chrome) | 16 | t.Fatalf("empty chrome = %#v, want zero content", chrome) | ||
| 17 | } | 17 | } | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | - | func TestBuildChromeShowsStoredPlan(t *testing.T) { | 20 | + | func TestBuildChromeUsesCompactSummaryWithoutSpecText(t *testing.T) { |
| 21 | state := PlanState{ | 21 | state := PlanState{ | ||
| 22 | Version: 1, | 22 | Version: 1, | ||
| 23 | - | Spec: "Ship the compact view", | 23 | + | Spec: "private spec text must not be persistent chrome", |
| 24 | - | Phases: []Phase{{ | 24 | + | Phases: []Phase{ |
| 25 | - | ID: "phase-1", | 25 | + | { |
| 26 | - | Title: "Implement", | 26 | + | ID: "phase-1", |
| 27 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Hide empty state"}}, | 27 | + | Title: "Finished discovery", |
| 28 | - | }}, | 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", | |||
| 29 | } | 40 | } | ||
| 30 | 41 | ||||
| 31 | chrome, visible := buildChrome(state) | 42 | chrome, visible := buildChrome(state) | ||
| 32 | if !visible { | 43 | if !visible { | ||
| 33 | t.Fatal("stored plan should publish persistent chrome") | 44 | t.Fatal("stored plan should publish persistent chrome") | ||
| 34 | } | 45 | } | ||
| 35 | - | if chrome.status != "0/1 tasks checked" { | 46 | + | if want := "p 1/2 | t 2/3"; chrome.summary != want { |
| 36 | - | t.Fatalf("chrome status = %q, want %q", chrome.status, "0/1 tasks checked") | 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) | |||
| 37 | } | 51 | } | ||
| 38 | joined := strings.Join(chrome.lines, "\n") | 52 | joined := strings.Join(chrome.lines, "\n") | ||
| 39 | - | for _, want := range []string{"Spec: Ship the compact view", "Progress: 0/1 tasks checked", "Current phase: Implement"} { | 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 | + | } { | |||
| 40 | if !strings.Contains(joined, want) { | 59 | if !strings.Contains(joined, want) { | ||
| 41 | - | t.Fatalf("chrome lines missing %q: %s", want, joined) | 60 | + | t.Fatalf("checklist missing %q: %s", want, joined) |
| 42 | } | 61 | } | ||
| 43 | } | 62 | } | ||
| 44 | } | 63 | } | ||
| 45 | 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 | + | ||||
| 46 | type chromeRecorder struct { | 101 | type chromeRecorder struct { | ||
| 47 | statusSet int | 102 | statusSet int | ||
| 48 | statusClear int | 103 | statusClear int | ||
| 104 | + | statusTexts []string | |||
| 49 | widgetSet int | 105 | widgetSet int | ||
| 50 | widgetClear int | 106 | widgetClear int | ||
| 51 | widgetPositions []string | 107 | widgetPositions []string | ||
| 108 | + | widgetTitles []string | |||
| 109 | + | widgetLines [][]string | |||
| 52 | } | 110 | } | ||
| 53 | 111 | ||||
| 54 | - | func (r *chromeRecorder) SetStatus(string, string) { r.statusSet++ } | 112 | + | func (r *chromeRecorder) SetStatus(_, text string) { |
| 55 | - | func (r *chromeRecorder) ClearStatus(string) { r.statusClear++ } | 113 | + | r.statusSet++ |
| 56 | - | func (r *chromeRecorder) SetWidget(_, position, _ string, _ []string) { | 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) { | |||
| 57 | r.widgetSet++ | 118 | r.widgetSet++ | ||
| 58 | r.widgetPositions = append(r.widgetPositions, position) | 119 | r.widgetPositions = append(r.widgetPositions, position) | ||
| 120 | + | r.widgetTitles = append(r.widgetTitles, title) | |||
| 121 | + | r.widgetLines = append(r.widgetLines, append([]string(nil), lines...)) | |||
| 59 | } | 122 | } | ||
| 60 | func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ } | 123 | func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ } | ||
| 61 | 124 | ||||
| 62 | - | func TestApplyChromeUsesRightBar(t *testing.T) { | 125 | + | func TestApplyChromePublishesCompactStatusAndTitle(t *testing.T) { |
| 63 | recorder := &chromeRecorder{} | 126 | recorder := &chromeRecorder{} | ||
| 64 | - | applyChrome(recorder, PlanState{ | 127 | + | state := PlanState{ |
| 65 | Version: 1, | 128 | Version: 1, | ||
| 66 | - | Spec: "Keep the plan visible", | 129 | + | Spec: "must not be shown", |
| 67 | - | Phases: []Phase{{ | 130 | + | Phases: []Phase{ |
| 68 | - | ID: "phase-1", | 131 | + | { |
| 69 | - | Title: "Implement", | 132 | + | ID: "phase-1", |
| 70 | - | Tasks: []PhaseTask{{ID: "task-1", Text: "Ship"}}, | 133 | + | Title: "Finished", |
| 71 | - | }}, | 134 | + | Tasks: []PhaseTask{{ID: "task-1", Text: "Ship", Checked: true}}, |
| 72 | - | }) | 135 | + | }, |
| 136 | + | { | |||
| 137 | + | ID: "phase-2", | |||
| 138 | + | Title: "Pending", | |||
| 139 | + | Tasks: []PhaseTask{{ID: "task-2", Text: "Review"}}, | |||
| 140 | + | }, | |||
| 141 | + | }, | |||
| 142 | + | } | |||
| 143 | + | applyChrome(recorder, state) | |||
| 73 | 144 | ||||
| 74 | if recorder.widgetSet != 1 || len(recorder.widgetPositions) != 1 || recorder.widgetPositions[0] != ext.WidgetPositionRightBar { | 145 | if recorder.widgetSet != 1 || len(recorder.widgetPositions) != 1 || recorder.widgetPositions[0] != ext.WidgetPositionRightBar { | ||
| 75 | t.Fatalf("widget placement = %v, want %q", recorder.widgetPositions, ext.WidgetPositionRightBar) | 146 | t.Fatalf("widget placement = %v, want %q", recorder.widgetPositions, ext.WidgetPositionRightBar) | ||
| 76 | } | 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 | + | } | |||
| 77 | } | 161 | } | ||
| 78 | 162 | ||||
| 79 | func TestApplyChromeClearsEmptyPlan(t *testing.T) { | 163 | func TestApplyChromeClearsEmptyPlan(t *testing.T) { | ||
| @@ -87,3 +171,42 @@ func TestApplyChromeClearsEmptyPlan(t *testing.T) { | |||||
| 87 | t.Fatalf("empty plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet) | 171 | t.Fatalf("empty plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet) | ||
| 88 | } | 172 | } | ||
| 89 | } | 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 | + | } | |||
M examples/extensions/tasked-phases/skills/tasked-phases/SKILL.md +14 -0
| @@ -18,6 +18,20 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren | |||
| 18 | 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 | 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 | 20 | ||
| 21 | + | The right-bar summary and status bar are the persistent UI: both refresh after | |
| 22 | + | each successful mutation and session restore and use `p done/total | t done/total`. | |
| 23 | + | Active plans also publish their phase and task rows below the compact title; the | |
| 24 | + | host dims rows outside the current phase. Use `[x]`, `[ ]`, and `[>]` markers. | |
| 25 | + | Use `get_status` or `/phases` when the complete checklist is needed. Persistent | |
| 26 | + | chrome is cleared automatically once every phase and task is complete. Keep | |
| 27 | + | phase titles to 32 characters or fewer and task text to 64 characters or fewer | |
| 28 | + | in plans and detailed views. Routine successful mutations return one concise | |
| 29 | + | progress line; use `get_status` or `replace_plan` when detailed output is needed. | |
| 30 | + | `set_task_checked` changes one task. `set_phase_checked` intentionally checks | |
| 31 | + | or unchecks every task in one phase, so use it only for an intentional phase-wide | |
| 32 | + | update. Multiple individual checks in one turn are sequential calls, not an | |
| 33 | + | automatic bulk update. | |
| 34 | + | ||
| 21 | 35 | ## Planning rules | |
| 22 | 36 | ||
| 23 | 37 | - Prefer 3–7 phases unless the task is tiny. | |
| @@ -18,6 +18,20 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren | |||||
| 18 | 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task. | 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. | 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 | 20 | ||||
| 21 | + | The right-bar summary and status bar are the persistent UI: both refresh after | |||
| 22 | + | each successful mutation and session restore and use `p done/total | t done/total`. | |||
| 23 | + | Active plans also publish their phase and task rows below the compact title; the | |||
| 24 | + | host dims rows outside the current phase. Use `[x]`, `[ ]`, and `[>]` markers. | |||
| 25 | + | Use `get_status` or `/phases` when the complete checklist is needed. Persistent | |||
| 26 | + | chrome is cleared automatically once every phase and task is complete. Keep | |||
| 27 | + | phase titles to 32 characters or fewer and task text to 64 characters or fewer | |||
| 28 | + | in plans and detailed views. Routine successful mutations return one concise | |||
| 29 | + | progress line; use `get_status` or `replace_plan` when detailed output is needed. | |||
| 30 | + | `set_task_checked` changes one task. `set_phase_checked` intentionally checks | |||
| 31 | + | or unchecks every task in one phase, so use it only for an intentional phase-wide | |||
| 32 | + | update. Multiple individual checks in one turn are sequential calls, not an | |||
| 33 | + | automatic bulk update. | |||
| 34 | + | ||||
| 21 | ## Planning rules | 35 | ## Planning rules | ||
| 22 | 36 | ||||
| 23 | - Prefer 3–7 phases unless the task is tiny. | 37 | - Prefer 3–7 phases unless the task is tiny. | ||
M examples/extensions/tasked-phases/state.go +5 -49
| @@ -774,7 +774,6 @@ func checkedWord(checked bool) string { | |||
| 774 | 774 | } | |
| 775 | 775 | ||
| 776 | 776 | const ( | |
| 777 | - | compactVisibleIncompleteTasks = 4 | |
| 778 | 777 | turnContextVisibleIncompleteTasks = 3 | |
| 779 | 778 | maxTurnContextChars = 2048 | |
| 780 | 779 | ) | |
| @@ -789,13 +788,6 @@ func getIncompleteTasks(phase Phase) []PhaseTask { | |||
| 789 | 788 | return incomplete | |
| 790 | 789 | } | |
| 791 | 790 | ||
| 792 | - | func formatRemainingTaskCount(count int) string { | |
| 793 | - | if count == 1 { | |
| 794 | - | return "1 remaining" | |
| 795 | - | } | |
| 796 | - | return fmt.Sprintf("%d remaining", count) | |
| 797 | - | } | |
| 798 | - | ||
| 799 | 791 | func formatPhaseTitle(phase Phase) string { return truncatePlain(displaySingleLine(phase.Title), 120) } | |
| 800 | 792 | func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) } | |
| 801 | 793 | ||
| @@ -865,41 +857,6 @@ func buildSummary(state PlanState) string { | |||
| 865 | 857 | return strings.Join(lines, "\n") | |
| 866 | 858 | } | |
| 867 | 859 | ||
| 868 | - | func buildCompactSummary(state PlanState) string { | |
| 869 | - | if !hasStoredPlan(state) { | |
| 870 | - | return "No spec or phased checklist has been stored yet." | |
| 871 | - | } | |
| 872 | - | lines := make([]string, 0) | |
| 873 | - | if state.Spec != "" { | |
| 874 | - | lines = append(lines, "Spec: "+truncatePlain(displaySingleLine(state.Spec), 220)) | |
| 875 | - | } | |
| 876 | - | done, total := getPlanProgress(state) | |
| 877 | - | lines = append(lines, fmt.Sprintf("Progress: %d/%d tasks checked", done, total)) | |
| 878 | - | if isPlanClosed(state) { | |
| 879 | - | closedSummary := state.ClosedSummary | |
| 880 | - | if closedSummary == "" { | |
| 881 | - | closedSummary = "all phases complete" | |
| 882 | - | } | |
| 883 | - | lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary)) | |
| 884 | - | return strings.Join(lines, "\n") | |
| 885 | - | } | |
| 886 | - | if currentPhase := getActivePhase(state); currentPhase != nil { | |
| 887 | - | incompleteTasks := getIncompleteTasks(*currentPhase) | |
| 888 | - | goalSuffix := "" | |
| 889 | - | if currentPhase.Goal != "" { | |
| 890 | - | goalSuffix = " - " + truncatePlain(displaySingleLine(currentPhase.Goal), 120) | |
| 891 | - | } | |
| 892 | - | lines = append(lines, fmt.Sprintf("Current phase: %s [%s] (%s)%s", formatPhaseTitle(*currentPhase), formatTurnContextID(currentPhase.ID), formatRemainingTaskCount(len(incompleteTasks)), goalSuffix)) | |
| 893 | - | if len(incompleteTasks) > 0 { | |
| 894 | - | lines = append(lines, "Incomplete tasks:") | |
| 895 | - | appendIncompleteTaskLines(&lines, incompleteTasks, compactVisibleIncompleteTasks, " ") | |
| 896 | - | } | |
| 897 | - | } else if len(state.Phases) > 0 { | |
| 898 | - | lines = append(lines, "Current phase: none") | |
| 899 | - | } | |
| 900 | - | return strings.Join(lines, "\n") | |
| 901 | - | } | |
| 902 | - | ||
| 903 | 860 | // buildTurnContext returns the small amount of plan state the model needs at | |
| 904 | 861 | // the start of a turn. It deliberately excludes the spec, completed phases, | |
| 905 | 862 | // and future phases; get_status and /phases remain the full-state views. | |
| @@ -943,14 +900,13 @@ func shouldReturnFullSummary(action string, err error) bool { | |||
| 943 | 900 | } | |
| 944 | 901 | ||
| 945 | 902 | func buildToolResultText(action string, state PlanState, headline string, err error) string { | |
| 946 | - | summary := buildCompactSummary(state) | |
| 947 | - | if shouldReturnFullSummary(action, err) { | |
| 948 | - | summary = buildSummary(state) | |
| 949 | - | } | |
| 950 | 903 | if err != nil { | |
| 951 | - | return "Error: " + err.Error() + "\n\n" + summary | |
| 904 | + | return "Error: " + err.Error() + "\n\n" + buildSummary(state) | |
| 905 | + | } | |
| 906 | + | if shouldReturnFullSummary(action, nil) { | |
| 907 | + | return headline + "\n\n" + buildSummary(state) | |
| 952 | 908 | } | |
| 953 | - | return headline + "\n\n" + summary | |
| 909 | + | return fmt.Sprintf("%s (%s)", headline, buildChromeSummary(state)) | |
| 954 | 910 | } | |
| 955 | 911 | ||
| 956 | 912 | func buildViewLines(state PlanState) []string { | |
| @@ -774,7 +774,6 @@ func checkedWord(checked bool) string { | |||||
| 774 | } | 774 | } | ||
| 775 | 775 | ||||
| 776 | const ( | 776 | const ( | ||
| 777 | - | compactVisibleIncompleteTasks = 4 | |||
| 778 | turnContextVisibleIncompleteTasks = 3 | 777 | turnContextVisibleIncompleteTasks = 3 | ||
| 779 | maxTurnContextChars = 2048 | 778 | maxTurnContextChars = 2048 | ||
| 780 | ) | 779 | ) | ||
| @@ -789,13 +788,6 @@ func getIncompleteTasks(phase Phase) []PhaseTask { | |||||
| 789 | return incomplete | 788 | return incomplete | ||
| 790 | } | 789 | } | ||
| 791 | 790 | ||||
| 792 | - | func formatRemainingTaskCount(count int) string { | |||
| 793 | - | if count == 1 { | |||
| 794 | - | return "1 remaining" | |||
| 795 | - | } | |||
| 796 | - | return fmt.Sprintf("%d remaining", count) | |||
| 797 | - | } | |||
| 798 | - | ||||
| 799 | func formatPhaseTitle(phase Phase) string { return truncatePlain(displaySingleLine(phase.Title), 120) } | 791 | func formatPhaseTitle(phase Phase) string { return truncatePlain(displaySingleLine(phase.Title), 120) } | ||
| 800 | func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) } | 792 | func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) } | ||
| 801 | 793 | ||||
| @@ -865,41 +857,6 @@ func buildSummary(state PlanState) string { | |||||
| 865 | return strings.Join(lines, "\n") | 857 | return strings.Join(lines, "\n") | ||
| 866 | } | 858 | } | ||
| 867 | 859 | ||||
| 868 | - | func buildCompactSummary(state PlanState) string { | |||
| 869 | - | if !hasStoredPlan(state) { | |||
| 870 | - | return "No spec or phased checklist has been stored yet." | |||
| 871 | - | } | |||
| 872 | - | lines := make([]string, 0) | |||
| 873 | - | if state.Spec != "" { | |||
| 874 | - | lines = append(lines, "Spec: "+truncatePlain(displaySingleLine(state.Spec), 220)) | |||
| 875 | - | } | |||
| 876 | - | done, total := getPlanProgress(state) | |||
| 877 | - | lines = append(lines, fmt.Sprintf("Progress: %d/%d tasks checked", done, total)) | |||
| 878 | - | if isPlanClosed(state) { | |||
| 879 | - | closedSummary := state.ClosedSummary | |||
| 880 | - | if closedSummary == "" { | |||
| 881 | - | closedSummary = "all phases complete" | |||
| 882 | - | } | |||
| 883 | - | lines = append(lines, "Plan closed: "+displaySingleLine(closedSummary)) | |||
| 884 | - | return strings.Join(lines, "\n") | |||
| 885 | - | } | |||
| 886 | - | if currentPhase := getActivePhase(state); currentPhase != nil { | |||
| 887 | - | incompleteTasks := getIncompleteTasks(*currentPhase) | |||
| 888 | - | goalSuffix := "" | |||
| 889 | - | if currentPhase.Goal != "" { | |||
| 890 | - | goalSuffix = " - " + truncatePlain(displaySingleLine(currentPhase.Goal), 120) | |||
| 891 | - | } | |||
| 892 | - | lines = append(lines, fmt.Sprintf("Current phase: %s [%s] (%s)%s", formatPhaseTitle(*currentPhase), formatTurnContextID(currentPhase.ID), formatRemainingTaskCount(len(incompleteTasks)), goalSuffix)) | |||
| 893 | - | if len(incompleteTasks) > 0 { | |||
| 894 | - | lines = append(lines, "Incomplete tasks:") | |||
| 895 | - | appendIncompleteTaskLines(&lines, incompleteTasks, compactVisibleIncompleteTasks, " ") | |||
| 896 | - | } | |||
| 897 | - | } else if len(state.Phases) > 0 { | |||
| 898 | - | lines = append(lines, "Current phase: none") | |||
| 899 | - | } | |||
| 900 | - | return strings.Join(lines, "\n") | |||
| 901 | - | } | |||
| 902 | - | ||||
| 903 | // buildTurnContext returns the small amount of plan state the model needs at | 860 | // buildTurnContext returns the small amount of plan state the model needs at | ||
| 904 | // the start of a turn. It deliberately excludes the spec, completed phases, | 861 | // the start of a turn. It deliberately excludes the spec, completed phases, | ||
| 905 | // and future phases; get_status and /phases remain the full-state views. | 862 | // and future phases; get_status and /phases remain the full-state views. | ||
| @@ -943,14 +900,13 @@ func shouldReturnFullSummary(action string, err error) bool { | |||||
| 943 | } | 900 | } | ||
| 944 | 901 | ||||
| 945 | func buildToolResultText(action string, state PlanState, headline string, err error) string { | 902 | func buildToolResultText(action string, state PlanState, headline string, err error) string { | ||
| 946 | - | summary := buildCompactSummary(state) | |||
| 947 | - | if shouldReturnFullSummary(action, err) { | |||
| 948 | - | summary = buildSummary(state) | |||
| 949 | - | } | |||
| 950 | if err != nil { | 903 | if err != nil { | ||
| 951 | - | return "Error: " + err.Error() + "\n\n" + summary | 904 | + | return "Error: " + err.Error() + "\n\n" + buildSummary(state) |
| 905 | + | } | |||
| 906 | + | if shouldReturnFullSummary(action, nil) { | |||
| 907 | + | return headline + "\n\n" + buildSummary(state) | |||
| 952 | } | 908 | } | ||
| 953 | - | return headline + "\n\n" + summary | 909 | + | return fmt.Sprintf("%s (%s)", headline, buildChromeSummary(state)) |
| 954 | } | 910 | } | ||
| 955 | 911 | ||||
| 956 | func buildViewLines(state PlanState) []string { | 912 | func buildViewLines(state PlanState) []string { | ||
M examples/extensions/tasked-phases/state_test.go +3 -6
| @@ -184,11 +184,8 @@ func TestPhaseLookupAcceptsRawBracketedSummaryAndUniqueTitle(t *testing.T) { | |||
| 184 | 184 | func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) { | |
| 185 | 185 | state := sampleState() | |
| 186 | 186 | compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil) | |
| 187 | - | if !strings.Contains(compact, "Progress: 3/4 tasks checked") || !strings.Contains(compact, "[ ] Remaining implementation task [task-4]") { | |
| 188 | - | t.Fatalf("compact summary omitted useful active state: %s", compact) | |
| 189 | - | } | |
| 190 | - | if strings.Contains(compact, "Phases:") || strings.Contains(compact, "Completed discovery task") { | |
| 191 | - | t.Fatalf("compact summary included full checklist: %s", compact) | |
| 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) | |
| 192 | 189 | } | |
| 193 | 190 | full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil) | |
| 194 | 191 | if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") { | |
| @@ -286,7 +283,7 @@ func TestDisplayRenderingSanitizesUntrustedTextWithoutMutatingState(t *testing.T | |||
| 286 | 283 | original := state | |
| 287 | 284 | ||
| 288 | 285 | for _, rendered := range []string{ | |
| 289 | - | buildSummary(state), buildCompactSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"), | |
| 286 | + | buildSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"), | |
| 290 | 287 | } { | |
| 291 | 288 | if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') { | |
| 292 | 289 | t.Fatalf("rendered text contains terminal/control injection: %q", rendered) | |
| @@ -184,11 +184,8 @@ func TestPhaseLookupAcceptsRawBracketedSummaryAndUniqueTitle(t *testing.T) { | |||||
| 184 | func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) { | 184 | func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) { | ||
| 185 | state := sampleState() | 185 | state := sampleState() | ||
| 186 | compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil) | 186 | compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil) | ||
| 187 | - | if !strings.Contains(compact, "Progress: 3/4 tasks checked") || !strings.Contains(compact, "[ ] Remaining implementation task [task-4]") { | 187 | + | if want := "Checked task task-3 (p 1/2 | t 3/4)"; compact != want { |
| 188 | - | t.Fatalf("compact summary omitted useful active state: %s", compact) | 188 | + | t.Fatalf("mutation result = %q, want %q", compact, want) |
| 189 | - | } | |||
| 190 | - | if strings.Contains(compact, "Phases:") || strings.Contains(compact, "Completed discovery task") { | |||
| 191 | - | t.Fatalf("compact summary included full checklist: %s", compact) | |||
| 192 | } | 189 | } | ||
| 193 | full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil) | 190 | full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil) | ||
| 194 | if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") { | 191 | if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") { | ||
| @@ -286,7 +283,7 @@ func TestDisplayRenderingSanitizesUntrustedTextWithoutMutatingState(t *testing.T | |||||
| 286 | original := state | 283 | original := state | ||
| 287 | 284 | ||||
| 288 | for _, rendered := range []string{ | 285 | for _, rendered := range []string{ | ||
| 289 | - | buildSummary(state), buildCompactSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"), | 286 | + | buildSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"), |
| 290 | } { | 287 | } { | ||
| 291 | if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') { | 288 | if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') { | ||
| 292 | t.Fatalf("rendered text contains terminal/control injection: %q", rendered) | 289 | t.Fatalf("rendered text contains terminal/control injection: %q", rendered) | ||
M examples/skills/tasked-phases/SKILL.md +14 -0
| @@ -18,6 +18,20 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren | |||
| 18 | 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 | 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 | 20 | ||
| 21 | + | The right-bar summary and status bar are the persistent UI: both refresh after | |
| 22 | + | each successful mutation and session restore and use `p done/total | t done/total`. | |
| 23 | + | Active plans also publish their phase and task rows below the compact title; the | |
| 24 | + | host dims rows outside the current phase. Use `[x]`, `[ ]`, and `[>]` markers. | |
| 25 | + | Use `get_status` or `/phases` when the complete checklist is needed. Persistent | |
| 26 | + | chrome is cleared automatically once every phase and task is complete. Keep | |
| 27 | + | phase titles to 32 characters or fewer and task text to 64 characters or fewer | |
| 28 | + | in plans and detailed views. Routine successful mutations return one concise | |
| 29 | + | progress line; use `get_status` or `replace_plan` when detailed output is needed. | |
| 30 | + | `set_task_checked` changes one task. `set_phase_checked` intentionally checks | |
| 31 | + | or unchecks every task in one phase, so use it only for an intentional phase-wide | |
| 32 | + | update. Multiple individual checks in one turn are sequential calls, not an | |
| 33 | + | automatic bulk update. | |
| 34 | + | ||
| 21 | 35 | ## Planning rules | |
| 22 | 36 | ||
| 23 | 37 | - Prefer 3–7 phases unless the task is tiny. | |
| @@ -18,6 +18,20 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren | |||||
| 18 | 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task. | 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. | 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 | 20 | ||||
| 21 | + | The right-bar summary and status bar are the persistent UI: both refresh after | |||
| 22 | + | each successful mutation and session restore and use `p done/total | t done/total`. | |||
| 23 | + | Active plans also publish their phase and task rows below the compact title; the | |||
| 24 | + | host dims rows outside the current phase. Use `[x]`, `[ ]`, and `[>]` markers. | |||
| 25 | + | Use `get_status` or `/phases` when the complete checklist is needed. Persistent | |||
| 26 | + | chrome is cleared automatically once every phase and task is complete. Keep | |||
| 27 | + | phase titles to 32 characters or fewer and task text to 64 characters or fewer | |||
| 28 | + | in plans and detailed views. Routine successful mutations return one concise | |||
| 29 | + | progress line; use `get_status` or `replace_plan` when detailed output is needed. | |||
| 30 | + | `set_task_checked` changes one task. `set_phase_checked` intentionally checks | |||
| 31 | + | or unchecks every task in one phase, so use it only for an intentional phase-wide | |||
| 32 | + | update. Multiple individual checks in one turn are sequential calls, not an | |||
| 33 | + | automatic bulk update. | |||
| 34 | + | ||||
| 21 | ## Planning rules | 35 | ## Planning rules | ||
| 22 | 36 | ||||
| 23 | - Prefer 3–7 phases unless the task is tiny. | 37 | - Prefer 3–7 phases unless the task is tiny. | ||
M packages/agent/modes/ext_notes_test.go +52 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| @@ -82,6 +83,28 @@ func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) { | |||
| 82 | 83 | } | |
| 83 | 84 | } | |
| 84 | 85 | ||
| 86 | + | func TestNarrowRightBarFallbackCapsCombinedExtensionChrome(t *testing.T) { | |
| 87 | + | i := newNotesTestInteractive() | |
| 88 | + | for n := 0; n < 3; n++ { | |
| 89 | + | lines := make([]string, 4) | |
| 90 | + | for row := range lines { | |
| 91 | + | lines[row] = fmt.Sprintf("extension-%d-line-%d", n, row) | |
| 92 | + | } | |
| 93 | + | i.SetWidget(fmt.Sprintf("extension-%d", n), "plan", "right_bar", "Plan", lines) | |
| 94 | + | } | |
| 95 | + | i.SetStatus("status-extension", "progress", "info", "still visible") | |
| 96 | + | ||
| 97 | + | i.mu.Lock() | |
| 98 | + | got := i.extensionChromeLinesForLayoutLocked(72, false, true) | |
| 99 | + | i.mu.Unlock() | |
| 100 | + | if len(got) != maxNarrowExtensionChromeRows { | |
| 101 | + | t.Fatalf("narrow fallback rows = %d, want %d: %v", len(got), maxNarrowExtensionChromeRows, got) | |
| 102 | + | } | |
| 103 | + | if !strings.Contains(strings.Join(got, "\n"), "extension chrome truncated") { | |
| 104 | + | t.Fatalf("narrow fallback omitted truncation marker: %v", got) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 85 | 108 | func TestPersistentExtensionStatusAndWidget(t *testing.T) { | |
| 86 | 109 | i := newNotesTestInteractive() | |
| 87 | 110 | i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked") | |
| @@ -170,7 +193,7 @@ func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) { | |||
| 170 | 193 | i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"}) | |
| 171 | 194 | i.redraw() | |
| 172 | 195 | ||
| 173 | - | out := term.String() | |
| 196 | + | out := stripANSIBytes(term.String()) | |
| 174 | 197 | if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") { | |
| 175 | 198 | t.Fatalf("wide redraw omitted right-bar content: %q", out) | |
| 176 | 199 | } | |
| @@ -178,3 +201,31 @@ func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) { | |||
| 178 | 201 | t.Fatal("wide redraw omitted the main-pane divider") | |
| 179 | 202 | } | |
| 180 | 203 | } | |
| 204 | + | ||
| 205 | + | func TestCtrlBTogglesRightBarAndFallsBackAboveInput(t *testing.T) { | |
| 206 | + | term := &alertTestTerminal{} | |
| 207 | + | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | |
| 208 | + | i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"}) | |
| 209 | + | ||
| 210 | + | if i.rightBarHidden { | |
| 211 | + | t.Fatal("right bar starts hidden") | |
| 212 | + | } | |
| 213 | + | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB}); done { | |
| 214 | + | t.Fatal("ctrl+b unexpectedly exited interactive mode") | |
| 215 | + | } | |
| 216 | + | if !i.rightBarHidden { | |
| 217 | + | t.Fatal("ctrl+b did not hide the right bar") | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | i.mu.Lock() | |
| 221 | + | fallback := strings.Join(i.extensionChromeLinesForLayoutLocked(80, false, true), "\n") | |
| 222 | + | i.mu.Unlock() | |
| 223 | + | if !strings.Contains(fallback, "Plan") { | |
| 224 | + | t.Fatalf("hidden right-bar widget did not fall back above input: %q", fallback) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB}) | |
| 228 | + | if i.rightBarHidden { | |
| 229 | + | t.Fatal("second ctrl+b did not show the right bar") | |
| 230 | + | } | |
| 231 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| @@ -82,6 +83,28 @@ func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) { | |||||
| 82 | } | 83 | } | ||
| 83 | } | 84 | } | ||
| 84 | 85 | ||||
| 86 | + | func TestNarrowRightBarFallbackCapsCombinedExtensionChrome(t *testing.T) { | |||
| 87 | + | i := newNotesTestInteractive() | |||
| 88 | + | for n := 0; n < 3; n++ { | |||
| 89 | + | lines := make([]string, 4) | |||
| 90 | + | for row := range lines { | |||
| 91 | + | lines[row] = fmt.Sprintf("extension-%d-line-%d", n, row) | |||
| 92 | + | } | |||
| 93 | + | i.SetWidget(fmt.Sprintf("extension-%d", n), "plan", "right_bar", "Plan", lines) | |||
| 94 | + | } | |||
| 95 | + | i.SetStatus("status-extension", "progress", "info", "still visible") | |||
| 96 | + | ||||
| 97 | + | i.mu.Lock() | |||
| 98 | + | got := i.extensionChromeLinesForLayoutLocked(72, false, true) | |||
| 99 | + | i.mu.Unlock() | |||
| 100 | + | if len(got) != maxNarrowExtensionChromeRows { | |||
| 101 | + | t.Fatalf("narrow fallback rows = %d, want %d: %v", len(got), maxNarrowExtensionChromeRows, got) | |||
| 102 | + | } | |||
| 103 | + | if !strings.Contains(strings.Join(got, "\n"), "extension chrome truncated") { | |||
| 104 | + | t.Fatalf("narrow fallback omitted truncation marker: %v", got) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 85 | func TestPersistentExtensionStatusAndWidget(t *testing.T) { | 108 | func TestPersistentExtensionStatusAndWidget(t *testing.T) { | ||
| 86 | i := newNotesTestInteractive() | 109 | i := newNotesTestInteractive() | ||
| 87 | i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked") | 110 | i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked") | ||
| @@ -170,7 +193,7 @@ func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) { | |||||
| 170 | i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"}) | 193 | i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"}) | ||
| 171 | i.redraw() | 194 | i.redraw() | ||
| 172 | 195 | ||||
| 173 | - | out := term.String() | 196 | + | out := stripANSIBytes(term.String()) |
| 174 | if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") { | 197 | if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") { | ||
| 175 | t.Fatalf("wide redraw omitted right-bar content: %q", out) | 198 | t.Fatalf("wide redraw omitted right-bar content: %q", out) | ||
| 176 | } | 199 | } | ||
| @@ -178,3 +201,31 @@ func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) { | |||||
| 178 | t.Fatal("wide redraw omitted the main-pane divider") | 201 | t.Fatal("wide redraw omitted the main-pane divider") | ||
| 179 | } | 202 | } | ||
| 180 | } | 203 | } | ||
| 204 | + | ||||
| 205 | + | func TestCtrlBTogglesRightBarAndFallsBackAboveInput(t *testing.T) { | |||
| 206 | + | term := &alertTestTerminal{} | |||
| 207 | + | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | |||
| 208 | + | i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"}) | |||
| 209 | + | ||||
| 210 | + | if i.rightBarHidden { | |||
| 211 | + | t.Fatal("right bar starts hidden") | |||
| 212 | + | } | |||
| 213 | + | if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB}); done { | |||
| 214 | + | t.Fatal("ctrl+b unexpectedly exited interactive mode") | |||
| 215 | + | } | |||
| 216 | + | if !i.rightBarHidden { | |||
| 217 | + | t.Fatal("ctrl+b did not hide the right bar") | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | i.mu.Lock() | |||
| 221 | + | fallback := strings.Join(i.extensionChromeLinesForLayoutLocked(80, false, true), "\n") | |||
| 222 | + | i.mu.Unlock() | |||
| 223 | + | if !strings.Contains(fallback, "Plan") { | |||
| 224 | + | t.Fatalf("hidden right-bar widget did not fall back above input: %q", fallback) | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlB}) | |||
| 228 | + | if i.rightBarHidden { | |||
| 229 | + | t.Fatal("second ctrl+b did not show the right bar") | |||
| 230 | + | } | |||
| 231 | + | } | |||
M packages/agent/modes/help.go +1 -0
| @@ -20,6 +20,7 @@ var helpKeyRows = [][2]string{ | |||
| 20 | 20 | {"ctrl+u / ctrl+k", "delete to start / end of line"}, | |
| 21 | 21 | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | |
| 22 | 22 | {"alt+← / alt+→", "jump one word back / forward"}, | |
| 23 | + | {"ctrl+b", "toggle the right sidebar"}, | |
| 23 | 24 | {"ctrl+l", "redraw the screen"}, | |
| 24 | 25 | {"ctrl+o", "expand / collapse long tool results"}, | |
| 25 | 26 | {"pgup / pgdn", "scroll the chat one page up / down"}, | |
| @@ -20,6 +20,7 @@ var helpKeyRows = [][2]string{ | |||||
| 20 | {"ctrl+u / ctrl+k", "delete to start / end of line"}, | 20 | {"ctrl+u / ctrl+k", "delete to start / end of line"}, | ||
| 21 | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | 21 | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | ||
| 22 | {"alt+← / alt+→", "jump one word back / forward"}, | 22 | {"alt+← / alt+→", "jump one word back / forward"}, | ||
| 23 | + | {"ctrl+b", "toggle the right sidebar"}, | |||
| 23 | {"ctrl+l", "redraw the screen"}, | 24 | {"ctrl+l", "redraw the screen"}, | ||
| 24 | {"ctrl+o", "expand / collapse long tool results"}, | 25 | {"ctrl+o", "expand / collapse long tool results"}, | ||
| 25 | {"pgup / pgdn", "scroll the chat one page up / down"}, | 26 | {"pgup / pgdn", "scroll the chat one page up / down"}, | ||
M packages/agent/modes/help_test.go +3 -0
| @@ -9,6 +9,9 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | |
| 11 | 11 | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | |
| 12 | + | if !strings.Contains(without, "ctrl+b") { | |
| 13 | + | t.Fatalf("help omitted the right-sidebar shortcut: %q", without) | |
| 14 | + | } | |
| 12 | 15 | if strings.Contains(without, "/llama") { | |
| 13 | 16 | t.Fatalf("help exposed /llama without login: %q", without) | |
| 14 | 17 | } | |
| @@ -9,6 +9,9 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | 10 | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | ||
| 11 | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | 11 | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | ||
| 12 | + | if !strings.Contains(without, "ctrl+b") { | |||
| 13 | + | t.Fatalf("help omitted the right-sidebar shortcut: %q", without) | |||
| 14 | + | } | |||
| 12 | if strings.Contains(without, "/llama") { | 15 | if strings.Contains(without, "/llama") { | ||
| 13 | t.Fatalf("help exposed /llama without login: %q", without) | 16 | t.Fatalf("help exposed /llama without login: %q", without) | ||
| 14 | } | 17 | } | ||
M packages/agent/modes/interactive.go +26 -2
| @@ -514,6 +514,7 @@ type Interactive struct { | |||
| 514 | 514 | reloadStatusSeq uint64 | |
| 515 | 515 | extStatuses map[string]map[string]extensionStatus | |
| 516 | 516 | extWidgets map[string]map[string]extensionWidget | |
| 517 | + | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | |
| 517 | 518 | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| 518 | 519 | helpBlock []string // rendered above the chat when /help was typed | |
| 519 | 520 | cumUsage provider.Usage | |
| @@ -1235,6 +1236,10 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||
| 1235 | 1236 | const maxExtensionWidgetRows = 12 | |
| 1236 | 1237 | const maxExtensionStatusRows = 6 | |
| 1237 | 1238 | ||
| 1239 | + | // maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back | |
| 1240 | + | // above the input, leaving one row for the truncation marker. | |
| 1241 | + | const maxNarrowExtensionChromeRows = 6 | |
| 1242 | + | ||
| 1238 | 1243 | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |
| 1239 | 1244 | var out []tui.RightBarWidget | |
| 1240 | 1245 | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| @@ -1258,6 +1263,17 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |||
| 1258 | 1263 | return i.extensionChromeLinesAtLocked(cols, false) | |
| 1259 | 1264 | } | |
| 1260 | 1265 | ||
| 1266 | + | func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string { | |
| 1267 | + | lines := i.extensionChromeLinesAtLocked(cols, rightBarActive) | |
| 1268 | + | if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows { | |
| 1269 | + | return lines | |
| 1270 | + | } | |
| 1271 | + | limit := maxNarrowExtensionChromeRows | |
| 1272 | + | trimmed := append([]string(nil), lines[:limit-1]...) | |
| 1273 | + | trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols))) | |
| 1274 | + | return trimmed | |
| 1275 | + | } | |
| 1276 | + | ||
| 1261 | 1277 | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |
| 1262 | 1278 | var out []string | |
| 1263 | 1279 | bodyWidth := cols - 2 | |
| @@ -1591,13 +1607,14 @@ func (i *Interactive) redraw() { | |||
| 1591 | 1607 | rightBarWidgets := i.rightBarWidgetsLocked() | |
| 1592 | 1608 | rightBarActive := false | |
| 1593 | 1609 | var rightBarWidth int | |
| 1594 | - | if len(rightBarWidgets) > 0 { | |
| 1610 | + | if !i.rightBarHidden && len(rightBarWidgets) > 0 { | |
| 1595 | 1611 | if width, rail, ok := tui.RightBarColumns(cols); ok { | |
| 1596 | 1612 | mainCols = width | |
| 1597 | 1613 | rightBarWidth = rail | |
| 1598 | 1614 | rightBarActive = true | |
| 1599 | 1615 | } | |
| 1600 | 1616 | } | |
| 1617 | + | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |
| 1601 | 1618 | chat := i.cachedChatLocked(mainCols) | |
| 1602 | 1619 | ||
| 1603 | 1620 | // Dialogs (login or model picker) render between chat and the editor. | |
| @@ -1834,7 +1851,7 @@ func (i *Interactive) redraw() { | |||
| 1834 | 1851 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | |
| 1835 | 1852 | } | |
| 1836 | 1853 | ||
| 1837 | - | extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive) | |
| 1854 | + | extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback) | |
| 1838 | 1855 | ||
| 1839 | 1856 | // Bottom-sticky sections (always visible, never scroll). Each | |
| 1840 | 1857 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | |
| @@ -2921,6 +2938,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2921 | 2938 | if i.ed.IsEmpty() && !i.busy { | |
| 2922 | 2939 | return true | |
| 2923 | 2940 | } | |
| 2941 | + | case tui.KeyCtrlB: | |
| 2942 | + | i.mu.Lock() | |
| 2943 | + | i.rightBarHidden = !i.rightBarHidden | |
| 2944 | + | i.mu.Unlock() | |
| 2945 | + | i.rend.Invalidate() | |
| 2946 | + | i.invalidate() | |
| 2947 | + | return false | |
| 2924 | 2948 | case tui.KeyCtrlL: | |
| 2925 | 2949 | i.rend.Clear() | |
| 2926 | 2950 | i.invalidate() | |
| @@ -514,6 +514,7 @@ type Interactive struct { | |||||
| 514 | reloadStatusSeq uint64 | 514 | reloadStatusSeq uint64 | ||
| 515 | extStatuses map[string]map[string]extensionStatus | 515 | extStatuses map[string]map[string]extensionStatus | ||
| 516 | extWidgets map[string]map[string]extensionWidget | 516 | extWidgets map[string]map[string]extensionWidget | ||
| 517 | + | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | |||
| 517 | liveBlock []string // live streaming/tool progress rendered outside scrollback | 518 | liveBlock []string // live streaming/tool progress rendered outside scrollback | ||
| 518 | helpBlock []string // rendered above the chat when /help was typed | 519 | helpBlock []string // rendered above the chat when /help was typed | ||
| 519 | cumUsage provider.Usage | 520 | cumUsage provider.Usage | ||
| @@ -1235,6 +1236,10 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||||
| 1235 | const maxExtensionWidgetRows = 12 | 1236 | const maxExtensionWidgetRows = 12 | ||
| 1236 | const maxExtensionStatusRows = 6 | 1237 | const maxExtensionStatusRows = 6 | ||
| 1237 | 1238 | ||||
| 1239 | + | // maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back | |||
| 1240 | + | // above the input, leaving one row for the truncation marker. | |||
| 1241 | + | const maxNarrowExtensionChromeRows = 6 | |||
| 1242 | + | ||||
| 1238 | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | 1243 | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | ||
| 1239 | var out []tui.RightBarWidget | 1244 | var out []tui.RightBarWidget | ||
| 1240 | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | 1245 | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | ||
| @@ -1258,6 +1263,17 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |||||
| 1258 | return i.extensionChromeLinesAtLocked(cols, false) | 1263 | return i.extensionChromeLinesAtLocked(cols, false) | ||
| 1259 | } | 1264 | } | ||
| 1260 | 1265 | ||||
| 1266 | + | func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string { | |||
| 1267 | + | lines := i.extensionChromeLinesAtLocked(cols, rightBarActive) | |||
| 1268 | + | if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows { | |||
| 1269 | + | return lines | |||
| 1270 | + | } | |||
| 1271 | + | limit := maxNarrowExtensionChromeRows | |||
| 1272 | + | trimmed := append([]string(nil), lines[:limit-1]...) | |||
| 1273 | + | trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols))) | |||
| 1274 | + | return trimmed | |||
| 1275 | + | } | |||
| 1276 | + | ||||
| 1261 | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | 1277 | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | ||
| 1262 | var out []string | 1278 | var out []string | ||
| 1263 | bodyWidth := cols - 2 | 1279 | bodyWidth := cols - 2 | ||
| @@ -1591,13 +1607,14 @@ func (i *Interactive) redraw() { | |||||
| 1591 | rightBarWidgets := i.rightBarWidgetsLocked() | 1607 | rightBarWidgets := i.rightBarWidgetsLocked() | ||
| 1592 | rightBarActive := false | 1608 | rightBarActive := false | ||
| 1593 | var rightBarWidth int | 1609 | var rightBarWidth int | ||
| 1594 | - | if len(rightBarWidgets) > 0 { | 1610 | + | if !i.rightBarHidden && len(rightBarWidgets) > 0 { |
| 1595 | if width, rail, ok := tui.RightBarColumns(cols); ok { | 1611 | if width, rail, ok := tui.RightBarColumns(cols); ok { | ||
| 1596 | mainCols = width | 1612 | mainCols = width | ||
| 1597 | rightBarWidth = rail | 1613 | rightBarWidth = rail | ||
| 1598 | rightBarActive = true | 1614 | rightBarActive = true | ||
| 1599 | } | 1615 | } | ||
| 1600 | } | 1616 | } | ||
| 1617 | + | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |||
| 1601 | chat := i.cachedChatLocked(mainCols) | 1618 | chat := i.cachedChatLocked(mainCols) | ||
| 1602 | 1619 | ||||
| 1603 | // Dialogs (login or model picker) render between chat and the editor. | 1620 | // Dialogs (login or model picker) render between chat and the editor. | ||
| @@ -1834,7 +1851,7 @@ func (i *Interactive) redraw() { | |||||
| 1834 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | 1851 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | ||
| 1835 | } | 1852 | } | ||
| 1836 | 1853 | ||||
| 1837 | - | extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive) | 1854 | + | extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback) |
| 1838 | 1855 | ||||
| 1839 | // Bottom-sticky sections (always visible, never scroll). Each | 1856 | // Bottom-sticky sections (always visible, never scroll). Each | ||
| 1840 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | 1857 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | ||
| @@ -2921,6 +2938,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2921 | if i.ed.IsEmpty() && !i.busy { | 2938 | if i.ed.IsEmpty() && !i.busy { | ||
| 2922 | return true | 2939 | return true | ||
| 2923 | } | 2940 | } | ||
| 2941 | + | case tui.KeyCtrlB: | |||
| 2942 | + | i.mu.Lock() | |||
| 2943 | + | i.rightBarHidden = !i.rightBarHidden | |||
| 2944 | + | i.mu.Unlock() | |||
| 2945 | + | i.rend.Invalidate() | |||
| 2946 | + | i.invalidate() | |||
| 2947 | + | return false | |||
| 2924 | case tui.KeyCtrlL: | 2948 | case tui.KeyCtrlL: | ||
| 2925 | i.rend.Clear() | 2949 | i.rend.Clear() | ||
| 2926 | i.invalidate() | 2950 | i.invalidate() | ||
M packages/tui/input.go +5 -0
| @@ -41,6 +41,7 @@ const ( | |||
| 41 | 41 | KeyCtrlU | |
| 42 | 42 | KeyCtrlK | |
| 43 | 43 | KeyCtrlA | |
| 44 | + | KeyCtrlB | |
| 44 | 45 | KeyCtrlE | |
| 45 | 46 | KeyCtrlW | |
| 46 | 47 | KeyCtrlO | |
| @@ -179,6 +180,8 @@ func (r *Reader) Read() (Key, error) { | |||
| 179 | 180 | return Key{Kind: KeyCtrlK}, nil | |
| 180 | 181 | case b == 0x01: | |
| 181 | 182 | return Key{Kind: KeyCtrlA}, nil | |
| 183 | + | case b == 0x02: | |
| 184 | + | return Key{Kind: KeyCtrlB}, nil | |
| 182 | 185 | case b == 0x05: | |
| 183 | 186 | return Key{Kind: KeyCtrlE}, nil | |
| 184 | 187 | case b == 0x17: | |
| @@ -488,6 +491,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 488 | 491 | return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 489 | 492 | case 'a', 'A': | |
| 490 | 493 | 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 | |
| 491 | 496 | case 'e', 'E': | |
| 492 | 497 | return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 493 | 498 | case 'w', 'W': | |
| @@ -41,6 +41,7 @@ const ( | |||||
| 41 | KeyCtrlU | 41 | KeyCtrlU | ||
| 42 | KeyCtrlK | 42 | KeyCtrlK | ||
| 43 | KeyCtrlA | 43 | KeyCtrlA | ||
| 44 | + | KeyCtrlB | |||
| 44 | KeyCtrlE | 45 | KeyCtrlE | ||
| 45 | KeyCtrlW | 46 | KeyCtrlW | ||
| 46 | KeyCtrlO | 47 | KeyCtrlO | ||
| @@ -179,6 +180,8 @@ func (r *Reader) Read() (Key, error) { | |||||
| 179 | return Key{Kind: KeyCtrlK}, nil | 180 | return Key{Kind: KeyCtrlK}, nil | ||
| 180 | case b == 0x01: | 181 | case b == 0x01: | ||
| 181 | return Key{Kind: KeyCtrlA}, nil | 182 | return Key{Kind: KeyCtrlA}, nil | ||
| 183 | + | case b == 0x02: | |||
| 184 | + | return Key{Kind: KeyCtrlB}, nil | |||
| 182 | case b == 0x05: | 185 | case b == 0x05: | ||
| 183 | return Key{Kind: KeyCtrlE}, nil | 186 | return Key{Kind: KeyCtrlE}, nil | ||
| 184 | case b == 0x17: | 187 | case b == 0x17: | ||
| @@ -488,6 +491,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 488 | return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true | 491 | return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 489 | case 'a', 'A': | 492 | case 'a', 'A': | ||
| 490 | return Key{Kind: KeyCtrlA, Shift: shift, Alt: alt, Ctrl: true}, true | 493 | 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 | |||
| 491 | case 'e', 'E': | 496 | case 'e', 'E': | ||
| 492 | return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true | 497 | return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 493 | case 'w', 'W': | 498 | case 'w', 'W': | ||
M packages/tui/input_test.go +12 -0
| @@ -62,6 +62,18 @@ func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |||
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | func TestReaderParsesCtrlB(t *testing.T) { | |
| 66 | + | for _, input := range []string{"\x02", "\x1b[98;5u", "\x1b[27;5;98~"} { | |
| 67 | + | k := readKey(t, input) | |
| 68 | + | if k.Kind != KeyCtrlB { | |
| 69 | + | t.Fatalf("Read(%q) kind=%v ctrl=%v, want ctrl+b", input, k.Kind, k.Ctrl) | |
| 70 | + | } | |
| 71 | + | if input != "\x02" && !k.Ctrl { | |
| 72 | + | t.Fatalf("Read(%q) ctrl=%v, want enhanced ctrl+b metadata", input, k.Ctrl) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 65 | 77 | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | |
| 66 | 78 | k := readKey(t, "\x1b[118;5u") | |
| 67 | 79 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |
| @@ -62,6 +62,18 @@ func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |||||
| 62 | } | 62 | } | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | + | func TestReaderParsesCtrlB(t *testing.T) { | |||
| 66 | + | for _, input := range []string{"\x02", "\x1b[98;5u", "\x1b[27;5;98~"} { | |||
| 67 | + | k := readKey(t, input) | |||
| 68 | + | if k.Kind != KeyCtrlB { | |||
| 69 | + | t.Fatalf("Read(%q) kind=%v ctrl=%v, want ctrl+b", input, k.Kind, k.Ctrl) | |||
| 70 | + | } | |||
| 71 | + | if input != "\x02" && !k.Ctrl { | |||
| 72 | + | t.Fatalf("Read(%q) ctrl=%v, want enhanced ctrl+b metadata", input, k.Ctrl) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 65 | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | 77 | func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) { | ||
| 66 | k := readKey(t, "\x1b[118;5u") | 78 | k := readKey(t, "\x1b[118;5u") | ||
| 67 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | 79 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | ||
M packages/tui/rightbar.go +217 -56
| @@ -57,31 +57,15 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) { | |||
| 57 | 57 | return available - rightBarWidth, rightBarWidth, true | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | // RenderRightBar renders a bounded, full-height side rail. Widgets are sorted | |
| 61 | - | // by extension name and ID so output stays deterministic even when extension | |
| 62 | - | // frames arrive concurrently. Content that does not fit is replaced by a | |
| 63 | - | // compact marker rather than overflowing the terminal or growing the frame. | |
| 60 | + | // RenderRightBar renders a bounded, full-height side rail without adding a | |
| 61 | + | // second frame around the host-owned separator. Widgets are sorted by | |
| 62 | + | // extension name and ID so output stays deterministic even when extension | |
| 63 | + | // frames arrive concurrently. Long content wraps to the rail width, and | |
| 64 | + | // content that does not fit is replaced by a compact marker. | |
| 64 | 65 | func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string { | |
| 65 | 66 | if width <= 0 || height <= 0 { | |
| 66 | 67 | return nil | |
| 67 | 68 | } | |
| 68 | - | if width == 1 { | |
| 69 | - | return make([]string, height) | |
| 70 | - | } | |
| 71 | - | if width == 2 { | |
| 72 | - | out := make([]string, height) | |
| 73 | - | for row := range out { | |
| 74 | - | out[row] = strings.Repeat(" ", width) | |
| 75 | - | } | |
| 76 | - | out[0] = th.FG256(th.Muted, "──") | |
| 77 | - | if height > 1 { | |
| 78 | - | out[height-1] = th.FG256(th.Muted, "──") | |
| 79 | - | } | |
| 80 | - | return out | |
| 81 | - | } | |
| 82 | - | if height == 1 { | |
| 83 | - | return []string{th.FG256(th.Muted, strings.Repeat("─", width))} | |
| 84 | - | } | |
| 85 | 69 | ||
| 86 | 70 | ordered := append([]RightBarWidget(nil), widgets...) | |
| 87 | 71 | sort.SliceStable(ordered, func(a, b int) bool { | |
| @@ -91,40 +75,91 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 91 | 75 | return ordered[a].ID < ordered[b].ID | |
| 92 | 76 | }) | |
| 93 | 77 | ||
| 94 | - | innerWidth := width - 2 | |
| 95 | - | contentRows := height - 2 | |
| 96 | - | content := make([]string, 0, contentRows) | |
| 78 | + | content := make([]string, 0, height) | |
| 97 | 79 | truncated := false | |
| 98 | - | appendContent := func(text string, color int) bool { | |
| 99 | - | if len(content) >= contentRows { | |
| 100 | - | truncated = true | |
| 101 | - | return false | |
| 80 | + | padLine := func(text string) string { | |
| 81 | + | text = truncateToWidth(text, width) | |
| 82 | + | if visible := visibleWidth(text); visible < width { | |
| 83 | + | text += strings.Repeat(" ", width-visible) | |
| 84 | + | } | |
| 85 | + | return text | |
| 86 | + | } | |
| 87 | + | paintLine := func(text string, color int, dim, phase bool) string { | |
| 88 | + | text = padLine(text) | |
| 89 | + | if color < 0 { | |
| 90 | + | return text | |
| 91 | + | } | |
| 92 | + | paint := func(value string, valueColor int) string { | |
| 93 | + | if dim { | |
| 94 | + | value = Dim(value) | |
| 95 | + | } | |
| 96 | + | return th.FG256(valueColor, value) | |
| 97 | + | } | |
| 98 | + | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | |
| 99 | + | if phase { | |
| 100 | + | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | |
| 101 | + | return paint(text[:markerStart], color) + | |
| 102 | + | paint(text[markerStart:markerEnd], th.Muted) + | |
| 103 | + | paint(text[markerEnd:nameStart], color) + | |
| 104 | + | paint(Bold(text[nameStart:nameEnd]), color) + | |
| 105 | + | paint(text[nameEnd:], color) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return paint(text[:markerStart], color) + | |
| 109 | + | paint(text[markerStart:markerEnd], th.Muted) + | |
| 110 | + | paint(text[markerEnd:], color) | |
| 102 | 111 | } | |
| 103 | - | text = strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ↩ ") | |
| 104 | - | text = truncateToWidth(text, innerWidth) | |
| 105 | - | if visible := visibleWidth(text); visible < innerWidth { | |
| 106 | - | text += strings.Repeat(" ", innerWidth-visible) | |
| 112 | + | if phase { | |
| 113 | + | return paint(Bold(text), color) | |
| 107 | 114 | } | |
| 108 | - | if color >= 0 { | |
| 109 | - | text = th.FG256(color, text) | |
| 115 | + | return paint(text, color) | |
| 116 | + | } | |
| 117 | + | appendContent := func(text string, color int, dim, phase bool) bool { | |
| 118 | + | text = strings.ReplaceAll(text, "\r", "") | |
| 119 | + | for _, physicalLine := range strings.Split(text, "\n") { | |
| 120 | + | wrapped := rightBarWrapLine(physicalLine, width) | |
| 121 | + | if len(wrapped) == 0 { | |
| 122 | + | wrapped = []string{""} | |
| 123 | + | } | |
| 124 | + | for _, line := range wrapped { | |
| 125 | + | if len(content) >= height { | |
| 126 | + | truncated = true | |
| 127 | + | return false | |
| 128 | + | } | |
| 129 | + | content = append(content, paintLine(line, color, dim, phase)) | |
| 130 | + | } | |
| 110 | 131 | } | |
| 111 | - | content = append(content, text) | |
| 112 | 132 | return true | |
| 113 | 133 | } | |
| 114 | 134 | ||
| 115 | 135 | for idx, widget := range ordered { | |
| 116 | - | if idx > 0 && !appendContent("", -1) { | |
| 136 | + | if idx > 0 && !appendContent("", -1, false, false) { | |
| 117 | 137 | break | |
| 118 | 138 | } | |
| 119 | 139 | label := "[" + widget.Extension + "]" | |
| 120 | 140 | if widget.Title != "" { | |
| 121 | 141 | label += " " + widget.Title | |
| 122 | 142 | } | |
| 123 | - | if !appendContent(label, th.Accent) { | |
| 143 | + | if !appendContent(label, th.Accent, false, false) { | |
| 144 | + | break | |
| 145 | + | } | |
| 146 | + | if !appendContent("", -1, false, false) { | |
| 124 | 147 | break | |
| 125 | 148 | } | |
| 149 | + | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | |
| 150 | + | if activePhaseIndent >= 0 { | |
| 151 | + | activePhaseIndent++ // host-owned line prefix | |
| 152 | + | } | |
| 153 | + | phaseIndent := rightBarChecklistIndent(widget.Lines) | |
| 154 | + | if phaseIndent >= 0 { | |
| 155 | + | phaseIndent++ // host-owned line prefix | |
| 156 | + | } | |
| 157 | + | activePhase := false | |
| 126 | 158 | for _, line := range widget.Lines { | |
| 127 | - | if !appendContent(" "+line, th.FG) { | |
| 159 | + | line = " " + line | |
| 160 | + | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | |
| 161 | + | phase := rightBarChecklistPhaseLine(line, phaseIndent) | |
| 162 | + | if !appendContent(line, th.FG, dim, phase) { | |
| 128 | 163 | break | |
| 129 | 164 | } | |
| 130 | 165 | } | |
| @@ -133,30 +168,156 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 133 | 168 | } | |
| 134 | 169 | } | |
| 135 | 170 | if truncated { | |
| 136 | - | marker := "... right bar ..." | |
| 137 | - | marker = truncateToWidth(marker, innerWidth) | |
| 138 | - | if len(content) == contentRows { | |
| 139 | - | content[len(content)-1] = "" | |
| 171 | + | marker := paintLine("... right bar ...", th.Muted, false, false) | |
| 172 | + | if len(content) == height { | |
| 173 | + | content[len(content)-1] = marker | |
| 174 | + | } else { | |
| 175 | + | content = append(content, marker) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | for len(content) < height { | |
| 179 | + | content = append(content, strings.Repeat(" ", width)) | |
| 180 | + | } | |
| 181 | + | return content | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) { | |
| 185 | + | start = 0 | |
| 186 | + | for start < len(text) && text[start] == ' ' { | |
| 187 | + | start++ | |
| 188 | + | } | |
| 189 | + | if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' { | |
| 190 | + | return 0, 0, 0, false | |
| 191 | + | } | |
| 192 | + | switch text[start+1] { | |
| 193 | + | case ' ', 'x', 'X', '>': | |
| 194 | + | return start, start + 3, text[start+1], true | |
| 195 | + | default: | |
| 196 | + | return 0, 0, 0, false | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func rightBarWrapLine(text string, width int) []string { | |
| 201 | + | _, end, _, ok := rightBarChecklistMarker(text) | |
| 202 | + | if !ok { | |
| 203 | + | return WrapANSILine(text, width) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | bodyStart := end | |
| 207 | + | if bodyStart < len(text) && text[bodyStart] == ' ' { | |
| 208 | + | bodyStart++ | |
| 209 | + | } | |
| 210 | + | prefix := text[:bodyStart] | |
| 211 | + | prefixWidth := visibleWidth(prefix) | |
| 212 | + | bodyWidth := width - prefixWidth | |
| 213 | + | if bodyWidth < 1 { | |
| 214 | + | return WrapANSILine(text, width) | |
| 215 | + | } | |
| 216 | + | bodyLines := WrapANSILine(text[bodyStart:], bodyWidth) | |
| 217 | + | continuation := strings.Repeat(" ", prefixWidth) | |
| 218 | + | wrapped := make([]string, len(bodyLines)) | |
| 219 | + | for i, bodyLine := range bodyLines { | |
| 220 | + | if i == 0 { | |
| 221 | + | wrapped[i] = prefix + bodyLine | |
| 222 | + | } else { | |
| 223 | + | wrapped[i] = continuation + bodyLine | |
| 140 | 224 | } | |
| 141 | - | if !appendContent(marker, th.Muted) && len(content) > 0 { | |
| 142 | - | // appendContent cannot append when the row budget is full, so | |
| 143 | - | // replace the final row with the already-padded marker instead. | |
| 144 | - | marker = marker + strings.Repeat(" ", max(0, innerWidth-visibleWidth(marker))) | |
| 145 | - | content[len(content)-1] = th.FG256(th.Muted, marker) | |
| 225 | + | } | |
| 226 | + | return wrapped | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func rightBarChecklistIndent(lines []string) int { | |
| 230 | + | indent := -1 | |
| 231 | + | for _, line := range lines { | |
| 232 | + | start, _, _, ok := rightBarChecklistMarker(line) | |
| 233 | + | if ok && (indent < 0 || start < indent) { | |
| 234 | + | indent = start | |
| 146 | 235 | } | |
| 147 | 236 | } | |
| 237 | + | return indent | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func rightBarChecklistPhaseLine(text string, phaseIndent int) bool { | |
| 241 | + | if phaseIndent < 0 { | |
| 242 | + | return false | |
| 243 | + | } | |
| 244 | + | start, _, _, ok := rightBarChecklistMarker(text) | |
| 245 | + | return ok && start == phaseIndent | |
| 246 | + | } | |
| 148 | 247 | ||
| 149 | - | border := func(glyph string) string { return th.FG256(th.Muted, glyph) } | |
| 150 | - | out := make([]string, 0, height) | |
| 151 | - | out = append(out, border("┌")+strings.Repeat("─", innerWidth)+border("┐")) | |
| 152 | - | for _, line := range content { | |
| 153 | - | out = append(out, border("│")+line+border("│")) | |
| 248 | + | func rightBarChecklistPhaseName(text string) (start, end int, ok bool) { | |
| 249 | + | _, markerEnd, _, markerOK := rightBarChecklistMarker(text) | |
| 250 | + | if !markerOK { | |
| 251 | + | return 0, 0, false | |
| 252 | + | } | |
| 253 | + | start = markerEnd | |
| 254 | + | if start < len(text) && text[start] == ' ' { | |
| 255 | + | start++ | |
| 256 | + | } | |
| 257 | + | trimmedEnd := len(strings.TrimRight(text, " ")) | |
| 258 | + | if trimmedEnd <= start { | |
| 259 | + | return 0, 0, false | |
| 260 | + | } | |
| 261 | + | suffix := text[start:trimmedEnd] | |
| 262 | + | separator := strings.LastIndex(suffix, " ") | |
| 263 | + | if separator < 0 { | |
| 264 | + | return 0, 0, false | |
| 265 | + | } | |
| 266 | + | nameEnd := start + separator | |
| 267 | + | progress := strings.TrimSpace(text[nameEnd:trimmedEnd]) | |
| 268 | + | if !rightBarProgressToken(progress) { | |
| 269 | + | return 0, 0, false | |
| 270 | + | } | |
| 271 | + | for nameEnd > start && text[nameEnd-1] == ' ' { | |
| 272 | + | nameEnd-- | |
| 273 | + | } | |
| 274 | + | if nameEnd <= start { | |
| 275 | + | return 0, 0, false | |
| 276 | + | } | |
| 277 | + | return start, nameEnd, true | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func rightBarProgressToken(value string) bool { | |
| 281 | + | separator := strings.IndexByte(value, '/') | |
| 282 | + | if separator <= 0 || separator == len(value)-1 { | |
| 283 | + | return false | |
| 284 | + | } | |
| 285 | + | for _, part := range []string{value[:separator], value[separator+1:]} { | |
| 286 | + | for _, r := range part { | |
| 287 | + | if r < '0' || r > '9' { | |
| 288 | + | return false | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | return true | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func rightBarActivePhaseIndent(lines []string) int { | |
| 296 | + | for _, line := range lines { | |
| 297 | + | start, _, marker, ok := rightBarChecklistMarker(line) | |
| 298 | + | if ok && marker == '>' { | |
| 299 | + | return start | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | return -1 | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool { | |
| 306 | + | if activePhaseIndent < 0 { | |
| 307 | + | return false | |
| 308 | + | } | |
| 309 | + | start, _, marker, ok := rightBarChecklistMarker(text) | |
| 310 | + | if !ok { | |
| 311 | + | return false | |
| 312 | + | } | |
| 313 | + | if start == activePhaseIndent { | |
| 314 | + | *activePhase = marker == '>' | |
| 315 | + | return !*activePhase | |
| 154 | 316 | } | |
| 155 | - | for len(out) < height-1 { | |
| 156 | - | out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│")) | |
| 317 | + | if start > activePhaseIndent { | |
| 318 | + | return !*activePhase | |
| 157 | 319 | } | |
| 158 | - | out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘")) | |
| 159 | - | return out | |
| 320 | + | return false | |
| 160 | 321 | } | |
| 161 | 322 | ||
| 162 | 323 | // JoinRightBar combines one main-pane row and one right-bar row without | |
| @@ -57,31 +57,15 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) { | |||||
| 57 | return available - rightBarWidth, rightBarWidth, true | 57 | return available - rightBarWidth, rightBarWidth, true | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | - | // RenderRightBar renders a bounded, full-height side rail. Widgets are sorted | 60 | + | // RenderRightBar renders a bounded, full-height side rail without adding a |
| 61 | - | // by extension name and ID so output stays deterministic even when extension | 61 | + | // second frame around the host-owned separator. Widgets are sorted by |
| 62 | - | // frames arrive concurrently. Content that does not fit is replaced by a | 62 | + | // extension name and ID so output stays deterministic even when extension |
| 63 | - | // compact marker rather than overflowing the terminal or growing the frame. | 63 | + | // frames arrive concurrently. Long content wraps to the rail width, and |
| 64 | + | // content that does not fit is replaced by a compact marker. | |||
| 64 | func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string { | 65 | func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string { | ||
| 65 | if width <= 0 || height <= 0 { | 66 | if width <= 0 || height <= 0 { | ||
| 66 | return nil | 67 | return nil | ||
| 67 | } | 68 | } | ||
| 68 | - | if width == 1 { | |||
| 69 | - | return make([]string, height) | |||
| 70 | - | } | |||
| 71 | - | if width == 2 { | |||
| 72 | - | out := make([]string, height) | |||
| 73 | - | for row := range out { | |||
| 74 | - | out[row] = strings.Repeat(" ", width) | |||
| 75 | - | } | |||
| 76 | - | out[0] = th.FG256(th.Muted, "──") | |||
| 77 | - | if height > 1 { | |||
| 78 | - | out[height-1] = th.FG256(th.Muted, "──") | |||
| 79 | - | } | |||
| 80 | - | return out | |||
| 81 | - | } | |||
| 82 | - | if height == 1 { | |||
| 83 | - | return []string{th.FG256(th.Muted, strings.Repeat("─", width))} | |||
| 84 | - | } | |||
| 85 | 69 | ||||
| 86 | ordered := append([]RightBarWidget(nil), widgets...) | 70 | ordered := append([]RightBarWidget(nil), widgets...) | ||
| 87 | sort.SliceStable(ordered, func(a, b int) bool { | 71 | sort.SliceStable(ordered, func(a, b int) bool { | ||
| @@ -91,40 +75,91 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||||
| 91 | return ordered[a].ID < ordered[b].ID | 75 | return ordered[a].ID < ordered[b].ID | ||
| 92 | }) | 76 | }) | ||
| 93 | 77 | ||||
| 94 | - | innerWidth := width - 2 | 78 | + | content := make([]string, 0, height) |
| 95 | - | contentRows := height - 2 | |||
| 96 | - | content := make([]string, 0, contentRows) | |||
| 97 | truncated := false | 79 | truncated := false | ||
| 98 | - | appendContent := func(text string, color int) bool { | 80 | + | padLine := func(text string) string { |
| 99 | - | if len(content) >= contentRows { | 81 | + | text = truncateToWidth(text, width) |
| 100 | - | truncated = true | 82 | + | if visible := visibleWidth(text); visible < width { |
| 101 | - | return false | 83 | + | text += strings.Repeat(" ", width-visible) |
| 84 | + | } | |||
| 85 | + | return text | |||
| 86 | + | } | |||
| 87 | + | paintLine := func(text string, color int, dim, phase bool) string { | |||
| 88 | + | text = padLine(text) | |||
| 89 | + | if color < 0 { | |||
| 90 | + | return text | |||
| 91 | + | } | |||
| 92 | + | paint := func(value string, valueColor int) string { | |||
| 93 | + | if dim { | |||
| 94 | + | value = Dim(value) | |||
| 95 | + | } | |||
| 96 | + | return th.FG256(valueColor, value) | |||
| 97 | + | } | |||
| 98 | + | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | |||
| 99 | + | if phase { | |||
| 100 | + | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | |||
| 101 | + | return paint(text[:markerStart], color) + | |||
| 102 | + | paint(text[markerStart:markerEnd], th.Muted) + | |||
| 103 | + | paint(text[markerEnd:nameStart], color) + | |||
| 104 | + | paint(Bold(text[nameStart:nameEnd]), color) + | |||
| 105 | + | paint(text[nameEnd:], color) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | return paint(text[:markerStart], color) + | |||
| 109 | + | paint(text[markerStart:markerEnd], th.Muted) + | |||
| 110 | + | paint(text[markerEnd:], color) | |||
| 102 | } | 111 | } | ||
| 103 | - | text = strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ↩ ") | 112 | + | if phase { |
| 104 | - | text = truncateToWidth(text, innerWidth) | 113 | + | return paint(Bold(text), color) |
| 105 | - | if visible := visibleWidth(text); visible < innerWidth { | |||
| 106 | - | text += strings.Repeat(" ", innerWidth-visible) | |||
| 107 | } | 114 | } | ||
| 108 | - | if color >= 0 { | 115 | + | return paint(text, color) |
| 109 | - | text = th.FG256(color, text) | 116 | + | } |
| 117 | + | appendContent := func(text string, color int, dim, phase bool) bool { | |||
| 118 | + | text = strings.ReplaceAll(text, "\r", "") | |||
| 119 | + | for _, physicalLine := range strings.Split(text, "\n") { | |||
| 120 | + | wrapped := rightBarWrapLine(physicalLine, width) | |||
| 121 | + | if len(wrapped) == 0 { | |||
| 122 | + | wrapped = []string{""} | |||
| 123 | + | } | |||
| 124 | + | for _, line := range wrapped { | |||
| 125 | + | if len(content) >= height { | |||
| 126 | + | truncated = true | |||
| 127 | + | return false | |||
| 128 | + | } | |||
| 129 | + | content = append(content, paintLine(line, color, dim, phase)) | |||
| 130 | + | } | |||
| 110 | } | 131 | } | ||
| 111 | - | content = append(content, text) | |||
| 112 | return true | 132 | return true | ||
| 113 | } | 133 | } | ||
| 114 | 134 | ||||
| 115 | for idx, widget := range ordered { | 135 | for idx, widget := range ordered { | ||
| 116 | - | if idx > 0 && !appendContent("", -1) { | 136 | + | if idx > 0 && !appendContent("", -1, false, false) { |
| 117 | break | 137 | break | ||
| 118 | } | 138 | } | ||
| 119 | label := "[" + widget.Extension + "]" | 139 | label := "[" + widget.Extension + "]" | ||
| 120 | if widget.Title != "" { | 140 | if widget.Title != "" { | ||
| 121 | label += " " + widget.Title | 141 | label += " " + widget.Title | ||
| 122 | } | 142 | } | ||
| 123 | - | if !appendContent(label, th.Accent) { | 143 | + | if !appendContent(label, th.Accent, false, false) { |
| 144 | + | break | |||
| 145 | + | } | |||
| 146 | + | if !appendContent("", -1, false, false) { | |||
| 124 | break | 147 | break | ||
| 125 | } | 148 | } | ||
| 149 | + | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | |||
| 150 | + | if activePhaseIndent >= 0 { | |||
| 151 | + | activePhaseIndent++ // host-owned line prefix | |||
| 152 | + | } | |||
| 153 | + | phaseIndent := rightBarChecklistIndent(widget.Lines) | |||
| 154 | + | if phaseIndent >= 0 { | |||
| 155 | + | phaseIndent++ // host-owned line prefix | |||
| 156 | + | } | |||
| 157 | + | activePhase := false | |||
| 126 | for _, line := range widget.Lines { | 158 | for _, line := range widget.Lines { | ||
| 127 | - | if !appendContent(" "+line, th.FG) { | 159 | + | line = " " + line |
| 160 | + | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | |||
| 161 | + | phase := rightBarChecklistPhaseLine(line, phaseIndent) | |||
| 162 | + | if !appendContent(line, th.FG, dim, phase) { | |||
| 128 | break | 163 | break | ||
| 129 | } | 164 | } | ||
| 130 | } | 165 | } | ||
| @@ -133,30 +168,156 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||||
| 133 | } | 168 | } | ||
| 134 | } | 169 | } | ||
| 135 | if truncated { | 170 | if truncated { | ||
| 136 | - | marker := "... right bar ..." | 171 | + | marker := paintLine("... right bar ...", th.Muted, false, false) |
| 137 | - | marker = truncateToWidth(marker, innerWidth) | 172 | + | if len(content) == height { |
| 138 | - | if len(content) == contentRows { | 173 | + | content[len(content)-1] = marker |
| 139 | - | content[len(content)-1] = "" | 174 | + | } else { |
| 175 | + | content = append(content, marker) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | for len(content) < height { | |||
| 179 | + | content = append(content, strings.Repeat(" ", width)) | |||
| 180 | + | } | |||
| 181 | + | return content | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) { | |||
| 185 | + | start = 0 | |||
| 186 | + | for start < len(text) && text[start] == ' ' { | |||
| 187 | + | start++ | |||
| 188 | + | } | |||
| 189 | + | if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' { | |||
| 190 | + | return 0, 0, 0, false | |||
| 191 | + | } | |||
| 192 | + | switch text[start+1] { | |||
| 193 | + | case ' ', 'x', 'X', '>': | |||
| 194 | + | return start, start + 3, text[start+1], true | |||
| 195 | + | default: | |||
| 196 | + | return 0, 0, 0, false | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func rightBarWrapLine(text string, width int) []string { | |||
| 201 | + | _, end, _, ok := rightBarChecklistMarker(text) | |||
| 202 | + | if !ok { | |||
| 203 | + | return WrapANSILine(text, width) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | bodyStart := end | |||
| 207 | + | if bodyStart < len(text) && text[bodyStart] == ' ' { | |||
| 208 | + | bodyStart++ | |||
| 209 | + | } | |||
| 210 | + | prefix := text[:bodyStart] | |||
| 211 | + | prefixWidth := visibleWidth(prefix) | |||
| 212 | + | bodyWidth := width - prefixWidth | |||
| 213 | + | if bodyWidth < 1 { | |||
| 214 | + | return WrapANSILine(text, width) | |||
| 215 | + | } | |||
| 216 | + | bodyLines := WrapANSILine(text[bodyStart:], bodyWidth) | |||
| 217 | + | continuation := strings.Repeat(" ", prefixWidth) | |||
| 218 | + | wrapped := make([]string, len(bodyLines)) | |||
| 219 | + | for i, bodyLine := range bodyLines { | |||
| 220 | + | if i == 0 { | |||
| 221 | + | wrapped[i] = prefix + bodyLine | |||
| 222 | + | } else { | |||
| 223 | + | wrapped[i] = continuation + bodyLine | |||
| 140 | } | 224 | } | ||
| 141 | - | if !appendContent(marker, th.Muted) && len(content) > 0 { | 225 | + | } |
| 142 | - | // appendContent cannot append when the row budget is full, so | 226 | + | return wrapped |
| 143 | - | // replace the final row with the already-padded marker instead. | 227 | + | } |
| 144 | - | marker = marker + strings.Repeat(" ", max(0, innerWidth-visibleWidth(marker))) | 228 | + | |
| 145 | - | content[len(content)-1] = th.FG256(th.Muted, marker) | 229 | + | func rightBarChecklistIndent(lines []string) int { |
| 230 | + | indent := -1 | |||
| 231 | + | for _, line := range lines { | |||
| 232 | + | start, _, _, ok := rightBarChecklistMarker(line) | |||
| 233 | + | if ok && (indent < 0 || start < indent) { | |||
| 234 | + | indent = start | |||
| 146 | } | 235 | } | ||
| 147 | } | 236 | } | ||
| 237 | + | return indent | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func rightBarChecklistPhaseLine(text string, phaseIndent int) bool { | |||
| 241 | + | if phaseIndent < 0 { | |||
| 242 | + | return false | |||
| 243 | + | } | |||
| 244 | + | start, _, _, ok := rightBarChecklistMarker(text) | |||
| 245 | + | return ok && start == phaseIndent | |||
| 246 | + | } | |||
| 148 | 247 | ||||
| 149 | - | border := func(glyph string) string { return th.FG256(th.Muted, glyph) } | 248 | + | func rightBarChecklistPhaseName(text string) (start, end int, ok bool) { |
| 150 | - | out := make([]string, 0, height) | 249 | + | _, markerEnd, _, markerOK := rightBarChecklistMarker(text) |
| 151 | - | out = append(out, border("┌")+strings.Repeat("─", innerWidth)+border("┐")) | 250 | + | if !markerOK { |
| 152 | - | for _, line := range content { | 251 | + | return 0, 0, false |
| 153 | - | out = append(out, border("│")+line+border("│")) | 252 | + | } |
| 253 | + | start = markerEnd | |||
| 254 | + | if start < len(text) && text[start] == ' ' { | |||
| 255 | + | start++ | |||
| 256 | + | } | |||
| 257 | + | trimmedEnd := len(strings.TrimRight(text, " ")) | |||
| 258 | + | if trimmedEnd <= start { | |||
| 259 | + | return 0, 0, false | |||
| 260 | + | } | |||
| 261 | + | suffix := text[start:trimmedEnd] | |||
| 262 | + | separator := strings.LastIndex(suffix, " ") | |||
| 263 | + | if separator < 0 { | |||
| 264 | + | return 0, 0, false | |||
| 265 | + | } | |||
| 266 | + | nameEnd := start + separator | |||
| 267 | + | progress := strings.TrimSpace(text[nameEnd:trimmedEnd]) | |||
| 268 | + | if !rightBarProgressToken(progress) { | |||
| 269 | + | return 0, 0, false | |||
| 270 | + | } | |||
| 271 | + | for nameEnd > start && text[nameEnd-1] == ' ' { | |||
| 272 | + | nameEnd-- | |||
| 273 | + | } | |||
| 274 | + | if nameEnd <= start { | |||
| 275 | + | return 0, 0, false | |||
| 276 | + | } | |||
| 277 | + | return start, nameEnd, true | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func rightBarProgressToken(value string) bool { | |||
| 281 | + | separator := strings.IndexByte(value, '/') | |||
| 282 | + | if separator <= 0 || separator == len(value)-1 { | |||
| 283 | + | return false | |||
| 284 | + | } | |||
| 285 | + | for _, part := range []string{value[:separator], value[separator+1:]} { | |||
| 286 | + | for _, r := range part { | |||
| 287 | + | if r < '0' || r > '9' { | |||
| 288 | + | return false | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | return true | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func rightBarActivePhaseIndent(lines []string) int { | |||
| 296 | + | for _, line := range lines { | |||
| 297 | + | start, _, marker, ok := rightBarChecklistMarker(line) | |||
| 298 | + | if ok && marker == '>' { | |||
| 299 | + | return start | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | return -1 | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool { | |||
| 306 | + | if activePhaseIndent < 0 { | |||
| 307 | + | return false | |||
| 308 | + | } | |||
| 309 | + | start, _, marker, ok := rightBarChecklistMarker(text) | |||
| 310 | + | if !ok { | |||
| 311 | + | return false | |||
| 312 | + | } | |||
| 313 | + | if start == activePhaseIndent { | |||
| 314 | + | *activePhase = marker == '>' | |||
| 315 | + | return !*activePhase | |||
| 154 | } | 316 | } | ||
| 155 | - | for len(out) < height-1 { | 317 | + | if start > activePhaseIndent { |
| 156 | - | out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│")) | 318 | + | return !*activePhase |
| 157 | } | 319 | } | ||
| 158 | - | out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘")) | 320 | + | return false |
| 159 | - | return out | |||
| 160 | } | 321 | } | ||
| 161 | 322 | ||||
| 162 | // JoinRightBar combines one main-pane row and one right-bar row without | 323 | // JoinRightBar combines one main-pane row and one right-bar row without | ||
M packages/tui/rightbar_test.go +114 -3
| @@ -31,23 +31,134 @@ func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) { | |||
| 31 | 31 | t.Fatalf("right bar rows = %d, want 7", len(lines)) | |
| 32 | 32 | } | |
| 33 | 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) | |
| 34 | + | if width := visibleWidth(line); width != 24 { | |
| 35 | + | t.Fatalf("right bar line %d width = %d, want 24: %q", idx, width, line) | |
| 36 | 36 | } | |
| 37 | 37 | } | |
| 38 | 38 | joined := stripANSI(strings.Join(lines, "\n")) | |
| 39 | 39 | if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") { | |
| 40 | 40 | t.Fatalf("widgets are not deterministic by extension: %q", joined) | |
| 41 | 41 | } | |
| 42 | + | if strings.ContainsAny(joined, "┌┐└┘─") { | |
| 43 | + | t.Fatalf("right bar added an outer border: %q", joined) | |
| 44 | + | } | |
| 42 | 45 | ||
| 43 | 46 | truncated := RenderRightBar(Dark, []RightBarWidget{ | |
| 44 | - | {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f"}}, | |
| 47 | + | {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f", "g", "h"}}, | |
| 45 | 48 | }, 24, 7) | |
| 46 | 49 | if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") { | |
| 47 | 50 | t.Fatalf("truncation marker missing: %q", truncated) | |
| 48 | 51 | } | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 54 | + | func TestRenderRightBarWrapsLongWidgetLines(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 three") || !strings.Contains(plain, "four five six") { | |
| 65 | + | t.Fatalf("long widget line was not wrapped: %q", plain) | |
| 66 | + | } | |
| 67 | + | for idx, line := range lines { | |
| 68 | + | if width := visibleWidth(line); width != 16 { | |
| 69 | + | t.Fatalf("wrapped right bar line %d width = %d, want 16: %q", idx, width, line) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T) { | |
| 75 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 76 | + | Extension: "tasked-phases", | |
| 77 | + | Title: "p 0/1 | t 0/1", | |
| 78 | + | Lines: []string{ | |
| 79 | + | "[>] Copper Lantern Setup 0/1", | |
| 80 | + | " [ ] Sort the leftover star stickers", | |
| 81 | + | }, | |
| 82 | + | }}, 36, 8) | |
| 83 | + | plain := stripANSI(strings.Join(lines, "\n")) | |
| 84 | + | for _, want := range []string{ | |
| 85 | + | " [>] Copper Lantern Setup 0/1", | |
| 86 | + | " [ ] Sort the leftover star", | |
| 87 | + | " stickers", | |
| 88 | + | } { | |
| 89 | + | if !strings.Contains(plain, want) { | |
| 90 | + | t.Fatalf("right-bar checklist layout missing %q: %q", want, plain) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) { | |
| 94 | + | t.Fatalf("phase name is not bold: %q", lines) | |
| 95 | + | } | |
| 96 | + | if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) { | |
| 97 | + | t.Fatalf("task text should not be bold: %q", lines) | |
| 98 | + | } | |
| 99 | + | plainLines := strings.Split(plain, "\n") | |
| 100 | + | if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" { | |
| 101 | + | t.Fatalf("widget title is not followed by an empty row: %q", plain) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestRenderRightBarDimsChecklistMarkers(t *testing.T) { | |
| 106 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 107 | + | Extension: "plan", | |
| 108 | + | Lines: []string{"[x] done", "[>] active", "[ ] pending"}, | |
| 109 | + | }}, 24, 8) | |
| 110 | + | rendered := strings.Join(lines, "\n") | |
| 111 | + | for _, marker := range []string{"[x]", "[>]", "[ ]"} { | |
| 112 | + | if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) && | |
| 113 | + | !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) { | |
| 114 | + | t.Fatalf("marker %q was not muted: %q", marker, rendered) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | if !strings.Contains(stripANSI(rendered), " done") { | |
| 118 | + | t.Fatalf("checklist text was not kept readable: %q", rendered) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) { | |
| 123 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 124 | + | Extension: "plan", | |
| 125 | + | Lines: []string{ | |
| 126 | + | "[x] finished phase", | |
| 127 | + | " [x] finished task", | |
| 128 | + | "[>] active phase", | |
| 129 | + | " [>] active task", | |
| 130 | + | "[ ] pending phase", | |
| 131 | + | " [ ] pending task", | |
| 132 | + | }, | |
| 133 | + | }}, 32, 12) | |
| 134 | + | for _, line := range lines { | |
| 135 | + | if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") { | |
| 136 | + | if strings.Contains(line, "\x1b[2m") { | |
| 137 | + | t.Fatalf("active checklist row was dimmed: %q", line) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | plain := stripANSI(strings.Join(lines, "\n")) | |
| 142 | + | for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} { | |
| 143 | + | if !strings.Contains(plain, text) { | |
| 144 | + | t.Fatalf("inactive checklist text %q missing: %q", text, plain) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") { | |
| 148 | + | t.Fatalf("inactive checklist rows were not dimmed: %q", lines) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) { | |
| 153 | + | line := JoinRightBar(Dark, "main", "right", 8, 8) | |
| 154 | + | if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) { | |
| 155 | + | t.Fatalf("separator is not muted: %q", line) | |
| 156 | + | } | |
| 157 | + | if strings.ContainsAny(stripANSI(line), "┌┐└┘─") { | |
| 158 | + | t.Fatalf("joined right bar added an outer rule: %q", line) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 51 | 162 | func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |
| 52 | 163 | line := JoinRightBar(Dark, "界", "✓ done", 8, 6) | |
| 53 | 164 | if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want { | |
| @@ -31,23 +31,134 @@ func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) { | |||||
| 31 | t.Fatalf("right bar rows = %d, want 7", len(lines)) | 31 | t.Fatalf("right bar rows = %d, want 7", len(lines)) | ||
| 32 | } | 32 | } | ||
| 33 | for idx, line := range lines { | 33 | for idx, line := range lines { | ||
| 34 | - | if width := visibleWidth(line); width > 24 { | 34 | + | if width := visibleWidth(line); width != 24 { |
| 35 | - | t.Fatalf("right bar line %d width = %d, want <= 24: %q", idx, width, line) | 35 | + | t.Fatalf("right bar line %d width = %d, want 24: %q", idx, width, line) |
| 36 | } | 36 | } | ||
| 37 | } | 37 | } | ||
| 38 | joined := stripANSI(strings.Join(lines, "\n")) | 38 | joined := stripANSI(strings.Join(lines, "\n")) | ||
| 39 | if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") { | 39 | if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") { | ||
| 40 | t.Fatalf("widgets are not deterministic by extension: %q", joined) | 40 | t.Fatalf("widgets are not deterministic by extension: %q", joined) | ||
| 41 | } | 41 | } | ||
| 42 | + | if strings.ContainsAny(joined, "┌┐└┘─") { | |||
| 43 | + | t.Fatalf("right bar added an outer border: %q", joined) | |||
| 44 | + | } | |||
| 42 | 45 | ||||
| 43 | truncated := RenderRightBar(Dark, []RightBarWidget{ | 46 | truncated := RenderRightBar(Dark, []RightBarWidget{ | ||
| 44 | - | {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f"}}, | 47 | + | {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f", "g", "h"}}, |
| 45 | }, 24, 7) | 48 | }, 24, 7) | ||
| 46 | if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") { | 49 | if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") { | ||
| 47 | t.Fatalf("truncation marker missing: %q", truncated) | 50 | t.Fatalf("truncation marker missing: %q", truncated) | ||
| 48 | } | 51 | } | ||
| 49 | } | 52 | } | ||
| 50 | 53 | ||||
| 54 | + | func TestRenderRightBarWrapsLongWidgetLines(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 three") || !strings.Contains(plain, "four five six") { | |||
| 65 | + | t.Fatalf("long widget line was not wrapped: %q", plain) | |||
| 66 | + | } | |||
| 67 | + | for idx, line := range lines { | |||
| 68 | + | if width := visibleWidth(line); width != 16 { | |||
| 69 | + | t.Fatalf("wrapped right bar line %d width = %d, want 16: %q", idx, width, line) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T) { | |||
| 75 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 76 | + | Extension: "tasked-phases", | |||
| 77 | + | Title: "p 0/1 | t 0/1", | |||
| 78 | + | Lines: []string{ | |||
| 79 | + | "[>] Copper Lantern Setup 0/1", | |||
| 80 | + | " [ ] Sort the leftover star stickers", | |||
| 81 | + | }, | |||
| 82 | + | }}, 36, 8) | |||
| 83 | + | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 84 | + | for _, want := range []string{ | |||
| 85 | + | " [>] Copper Lantern Setup 0/1", | |||
| 86 | + | " [ ] Sort the leftover star", | |||
| 87 | + | " stickers", | |||
| 88 | + | } { | |||
| 89 | + | if !strings.Contains(plain, want) { | |||
| 90 | + | t.Fatalf("right-bar checklist layout missing %q: %q", want, plain) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) { | |||
| 94 | + | t.Fatalf("phase name is not bold: %q", lines) | |||
| 95 | + | } | |||
| 96 | + | if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) { | |||
| 97 | + | t.Fatalf("task text should not be bold: %q", lines) | |||
| 98 | + | } | |||
| 99 | + | plainLines := strings.Split(plain, "\n") | |||
| 100 | + | if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" { | |||
| 101 | + | t.Fatalf("widget title is not followed by an empty row: %q", plain) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestRenderRightBarDimsChecklistMarkers(t *testing.T) { | |||
| 106 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 107 | + | Extension: "plan", | |||
| 108 | + | Lines: []string{"[x] done", "[>] active", "[ ] pending"}, | |||
| 109 | + | }}, 24, 8) | |||
| 110 | + | rendered := strings.Join(lines, "\n") | |||
| 111 | + | for _, marker := range []string{"[x]", "[>]", "[ ]"} { | |||
| 112 | + | if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) && | |||
| 113 | + | !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) { | |||
| 114 | + | t.Fatalf("marker %q was not muted: %q", marker, rendered) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | if !strings.Contains(stripANSI(rendered), " done") { | |||
| 118 | + | t.Fatalf("checklist text was not kept readable: %q", rendered) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) { | |||
| 123 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 124 | + | Extension: "plan", | |||
| 125 | + | Lines: []string{ | |||
| 126 | + | "[x] finished phase", | |||
| 127 | + | " [x] finished task", | |||
| 128 | + | "[>] active phase", | |||
| 129 | + | " [>] active task", | |||
| 130 | + | "[ ] pending phase", | |||
| 131 | + | " [ ] pending task", | |||
| 132 | + | }, | |||
| 133 | + | }}, 32, 12) | |||
| 134 | + | for _, line := range lines { | |||
| 135 | + | if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") { | |||
| 136 | + | if strings.Contains(line, "\x1b[2m") { | |||
| 137 | + | t.Fatalf("active checklist row was dimmed: %q", line) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 142 | + | for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} { | |||
| 143 | + | if !strings.Contains(plain, text) { | |||
| 144 | + | t.Fatalf("inactive checklist text %q missing: %q", text, plain) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") { | |||
| 148 | + | t.Fatalf("inactive checklist rows were not dimmed: %q", lines) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) { | |||
| 153 | + | line := JoinRightBar(Dark, "main", "right", 8, 8) | |||
| 154 | + | if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) { | |||
| 155 | + | t.Fatalf("separator is not muted: %q", line) | |||
| 156 | + | } | |||
| 157 | + | if strings.ContainsAny(stripANSI(line), "┌┐└┘─") { | |||
| 158 | + | t.Fatalf("joined right bar added an outer rule: %q", line) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 51 | func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | 162 | func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | ||
| 52 | line := JoinRightBar(Dark, "界", "✓ done", 8, 6) | 163 | line := JoinRightBar(Dark, "界", "✓ done", 8, 6) | ||
| 53 | if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want { | 164 | if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want { | ||