Merge pull request #30 from bnema/feat/task-phases-rightbar
feat(tui): add right sidebar toggle and compact task chrome
19 files · +796 -186
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 or narrow widgets use a bounded above-input fallback. | | |
| 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 or narrow widgets use a bounded above-input fallback. | | |||
| 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 +4 -8
| @@ -426,11 +426,10 @@ hint with these interactive-zot values: | |||
| 426 | 426 | - `"right_bar"` keeps the widget in a display-only side rail beside the | |
| 427 | 427 | transcript when the terminal is wide enough. | |
| 428 | 428 | ||
| 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 | |
| 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. | |
| 429 | + | The host owns right-bar layout: it orders widgets, bounds width and height, | |
| 430 | + | and truncates content. Narrow terminals and `Ctrl+B` use a bounded | |
| 431 | + | `above_input` fallback. Empty or unknown positions keep the historical | |
| 432 | + | `above_input` behavior. Use `open_panel` for interaction or navigation. | |
| 434 | 433 | ||
| 435 | 434 | ```json | |
| 436 | 435 | {"type":"widget","id":"plan","position":"right_bar", | |
| @@ -723,9 +722,6 @@ e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines) | |||
| 723 | 722 | e.ClearWidget("plan") | |
| 724 | 723 | ``` | |
| 725 | 724 | ||
| 726 | - | `right_bar` widgets are display-only in the first version. Open an extension | |
| 727 | - | panel for editing or keyboard navigation. | |
| 728 | - | ||
| 729 | 725 | The SDK has four interceptor hooks, all optional: | |
| 730 | 726 | ||
| 731 | 727 | ```go | |
| @@ -426,11 +426,10 @@ hint with these interactive-zot values: | |||||
| 426 | - `"right_bar"` keeps the widget in a display-only side rail beside the | 426 | - `"right_bar"` keeps the widget in a display-only side rail beside the | ||
| 427 | transcript when the terminal is wide enough. | 427 | transcript when the terminal is wide enough. | ||
| 428 | 428 | ||||
| 429 | - | The right bar is host-owned: it bounds width and height, orders widgets by | 429 | + | The host owns right-bar layout: it orders widgets, bounds width and height, |
| 430 | - | extension and ID, and truncates content that does not fit. On narrow terminals | 430 | + | and truncates content. Narrow terminals and `Ctrl+B` use a bounded |
| 431 | - | it falls back automatically to `above_input`. Empty or unknown positions retain | 431 | + | `above_input` fallback. Empty or unknown positions keep the historical |
| 432 | - | the historical `above_input` behavior so older extensions remain compatible. | 432 | + | `above_input` behavior. Use `open_panel` for interaction or navigation. |
| 433 | - | Use `open_panel` when a widget needs interaction or navigation. | |||
| 434 | 433 | ||||
| 435 | ```json | 434 | ```json | ||
| 436 | {"type":"widget","id":"plan","position":"right_bar", | 435 | {"type":"widget","id":"plan","position":"right_bar", | ||
| @@ -723,9 +722,6 @@ e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines) | |||||
| 723 | e.ClearWidget("plan") | 722 | e.ClearWidget("plan") | ||
| 724 | ``` | 723 | ``` | ||
| 725 | 724 | ||||
| 726 | - | `right_bar` widgets are display-only in the first version. Open an extension | |||
| 727 | - | panel for editing or keyboard navigation. | |||
| 728 | - | ||||
| 729 | The SDK has four interceptor hooks, all optional: | 725 | The SDK has four interceptor hooks, all optional: | ||
| 730 | 726 | ||||
| 731 | ```go | 727 | ```go | ||
M examples/extensions/tasked-phases/README.md +8 -4
| @@ -18,6 +18,10 @@ set_current_phase set_task_checked set_phase_checked | |||
| 18 | 18 | clear | |
| 19 | 19 | ``` | |
| 20 | 20 | ||
| 21 | + | Successful mutations return one progress line; use `get_status` or | |
| 22 | + | `replace_plan` for details. `set_task_checked` changes one task; | |
| 23 | + | `set_phase_checked` changes every task in a phase. | |
| 24 | + | ||
| 21 | 25 | ## Build and run | |
| 22 | 26 | ||
| 23 | 27 | From the zot repository: | |
| @@ -63,7 +67,7 @@ as it is completed, and advance the current phase when work moves forward. | |||
| 63 | 67 | Completed phases and future phases are not repeated in every provider request; | |
| 64 | 68 | use `get_status` or `/phases` when the full checklist is needed. | |
| 65 | 69 | ||
| 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 | |
| 69 | - | narrow terminals. The extension does not implement any terminal layout code. | |
| 70 | + | Active plans publish `p done/total | t done/total` and checklist rows through | |
| 71 | + | `right_bar`. The host dims inactive phases, uses a bounded above-input fallback | |
| 72 | + | on narrow terminals or when `Ctrl+B` hides the rail, and clears the chrome when | |
| 73 | + | the plan is complete. Use `get_status` or `/phases` for full details. | |
| @@ -18,6 +18,10 @@ set_current_phase set_task_checked set_phase_checked | |||||
| 18 | clear | 18 | clear | ||
| 19 | ``` | 19 | ``` | ||
| 20 | 20 | ||||
| 21 | + | Successful mutations return one progress line; use `get_status` or | |||
| 22 | + | `replace_plan` for details. `set_task_checked` changes one task; | |||
| 23 | + | `set_phase_checked` changes every task in a phase. | |||
| 24 | + | ||||
| 21 | ## Build and run | 25 | ## Build and run | ||
| 22 | 26 | ||||
| 23 | From the zot repository: | 27 | From the zot repository: | ||
| @@ -63,7 +67,7 @@ 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; | 67 | Completed phases and future phases are not repeated in every provider request; | ||
| 64 | use `get_status` or `/phases` when the full checklist is needed. | 68 | use `get_status` or `/phases` when the full checklist is needed. | ||
| 65 | 69 | ||||
| 66 | - | When a plan is active, the extension publishes its compact checklist through the | 70 | + | Active plans publish `p done/total | t done/total` and checklist rows through |
| 67 | - | host's generic `right_bar` widget position. zot keeps it beside the transcript on | 71 | + | `right_bar`. The host dims inactive phases, uses a bounded above-input fallback |
| 68 | - | wide terminals and automatically falls back to the normal above-input widget on | 72 | + | on narrow terminals or when `Ctrl+B` hides the rail, and clears the chrome when |
| 69 | - | narrow terminals. The extension does not implement any terminal layout code. | 73 | + | the plan is complete. Use `get_status` or `/phases` for full details. |
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 +4 -0
| @@ -18,6 +18,10 @@ 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 | + | Successful mutations return one progress line; use `get_status` or | |
| 22 | + | `replace_plan` for details. `set_task_checked` changes one task; | |
| 23 | + | `set_phase_checked` changes every task in a phase. | |
| 24 | + | ||
| 21 | 25 | ## Planning rules | |
| 22 | 26 | ||
| 23 | 27 | - Prefer 3–7 phases unless the task is tiny. | |
| @@ -18,6 +18,10 @@ 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 | + | Successful mutations return one progress line; use `get_status` or | |||
| 22 | + | `replace_plan` for details. `set_task_checked` changes one task; | |||
| 23 | + | `set_phase_checked` changes every task in a phase. | |||
| 24 | + | ||||
| 21 | ## Planning rules | 25 | ## Planning rules | ||
| 22 | 26 | ||||
| 23 | - Prefer 3–7 phases unless the task is tiny. | 27 | - 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 +4 -0
| @@ -18,6 +18,10 @@ 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 | + | Successful mutations return one progress line; use `get_status` or | |
| 22 | + | `replace_plan` for details. `set_task_checked` changes one task; | |
| 23 | + | `set_phase_checked` changes every task in a phase. | |
| 24 | + | ||
| 21 | 25 | ## Planning rules | |
| 22 | 26 | ||
| 23 | 27 | - Prefer 3–7 phases unless the task is tiny. | |
| @@ -18,6 +18,10 @@ 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 | + | Successful mutations return one progress line; use `get_status` or | |||
| 22 | + | `replace_plan` for details. `set_task_checked` changes one task; | |||
| 23 | + | `set_phase_checked` changes every task in a phase. | |||
| 24 | + | ||||
| 21 | ## Planning rules | 25 | ## Planning rules | ||
| 22 | 26 | ||||
| 23 | - Prefer 3–7 phases unless the task is tiny. | 27 | - 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
| @@ -518,6 +518,7 @@ type Interactive struct { | |||
| 518 | 518 | reloadStatusSeq uint64 | |
| 519 | 519 | extStatuses map[string]map[string]extensionStatus | |
| 520 | 520 | extWidgets map[string]map[string]extensionWidget | |
| 521 | + | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | |
| 521 | 522 | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| 522 | 523 | helpBlock []string // rendered above the chat when /help was typed | |
| 523 | 524 | cumUsage provider.Usage | |
| @@ -1239,6 +1240,10 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||
| 1239 | 1240 | const maxExtensionWidgetRows = 12 | |
| 1240 | 1241 | const maxExtensionStatusRows = 6 | |
| 1241 | 1242 | ||
| 1243 | + | // maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back | |
| 1244 | + | // above the input, leaving one row for the truncation marker. | |
| 1245 | + | const maxNarrowExtensionChromeRows = 6 | |
| 1246 | + | ||
| 1242 | 1247 | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |
| 1243 | 1248 | var out []tui.RightBarWidget | |
| 1244 | 1249 | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| @@ -1262,6 +1267,17 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |||
| 1262 | 1267 | return i.extensionChromeLinesAtLocked(cols, false) | |
| 1263 | 1268 | } | |
| 1264 | 1269 | ||
| 1270 | + | func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string { | |
| 1271 | + | lines := i.extensionChromeLinesAtLocked(cols, rightBarActive) | |
| 1272 | + | if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows { | |
| 1273 | + | return lines | |
| 1274 | + | } | |
| 1275 | + | limit := maxNarrowExtensionChromeRows | |
| 1276 | + | trimmed := append([]string(nil), lines[:limit-1]...) | |
| 1277 | + | trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols))) | |
| 1278 | + | return trimmed | |
| 1279 | + | } | |
| 1280 | + | ||
| 1265 | 1281 | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |
| 1266 | 1282 | var out []string | |
| 1267 | 1283 | bodyWidth := cols - 2 | |
| @@ -1595,13 +1611,14 @@ func (i *Interactive) redraw() { | |||
| 1595 | 1611 | rightBarWidgets := i.rightBarWidgetsLocked() | |
| 1596 | 1612 | rightBarActive := false | |
| 1597 | 1613 | var rightBarWidth int | |
| 1598 | - | if len(rightBarWidgets) > 0 { | |
| 1614 | + | if !i.rightBarHidden && len(rightBarWidgets) > 0 { | |
| 1599 | 1615 | if width, rail, ok := tui.RightBarColumns(cols); ok { | |
| 1600 | 1616 | mainCols = width | |
| 1601 | 1617 | rightBarWidth = rail | |
| 1602 | 1618 | rightBarActive = true | |
| 1603 | 1619 | } | |
| 1604 | 1620 | } | |
| 1621 | + | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |
| 1605 | 1622 | chat := i.cachedChatLocked(mainCols) | |
| 1606 | 1623 | ||
| 1607 | 1624 | // Dialogs (login or model picker) render between chat and the editor. | |
| @@ -1838,7 +1855,7 @@ func (i *Interactive) redraw() { | |||
| 1838 | 1855 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | |
| 1839 | 1856 | } | |
| 1840 | 1857 | ||
| 1841 | - | extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive) | |
| 1858 | + | extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback) | |
| 1842 | 1859 | ||
| 1843 | 1860 | // Bottom-sticky sections (always visible, never scroll). Each | |
| 1844 | 1861 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | |
| @@ -2925,6 +2942,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2925 | 2942 | if i.ed.IsEmpty() && !i.busy { | |
| 2926 | 2943 | return true | |
| 2927 | 2944 | } | |
| 2945 | + | case tui.KeyCtrlB: | |
| 2946 | + | i.mu.Lock() | |
| 2947 | + | i.rightBarHidden = !i.rightBarHidden | |
| 2948 | + | i.mu.Unlock() | |
| 2949 | + | i.rend.Invalidate() | |
| 2950 | + | i.invalidate() | |
| 2951 | + | return false | |
| 2928 | 2952 | case tui.KeyCtrlL: | |
| 2929 | 2953 | i.rend.Clear() | |
| 2930 | 2954 | i.invalidate() | |
| @@ -518,6 +518,7 @@ type Interactive struct { | |||||
| 518 | reloadStatusSeq uint64 | 518 | reloadStatusSeq uint64 | ||
| 519 | extStatuses map[string]map[string]extensionStatus | 519 | extStatuses map[string]map[string]extensionStatus | ||
| 520 | extWidgets map[string]map[string]extensionWidget | 520 | extWidgets map[string]map[string]extensionWidget | ||
| 521 | + | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | |||
| 521 | liveBlock []string // live streaming/tool progress rendered outside scrollback | 522 | liveBlock []string // live streaming/tool progress rendered outside scrollback | ||
| 522 | helpBlock []string // rendered above the chat when /help was typed | 523 | helpBlock []string // rendered above the chat when /help was typed | ||
| 523 | cumUsage provider.Usage | 524 | cumUsage provider.Usage | ||
| @@ -1239,6 +1240,10 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||||
| 1239 | const maxExtensionWidgetRows = 12 | 1240 | const maxExtensionWidgetRows = 12 | ||
| 1240 | const maxExtensionStatusRows = 6 | 1241 | const maxExtensionStatusRows = 6 | ||
| 1241 | 1242 | ||||
| 1243 | + | // maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back | |||
| 1244 | + | // above the input, leaving one row for the truncation marker. | |||
| 1245 | + | const maxNarrowExtensionChromeRows = 6 | |||
| 1246 | + | ||||
| 1242 | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | 1247 | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | ||
| 1243 | var out []tui.RightBarWidget | 1248 | var out []tui.RightBarWidget | ||
| 1244 | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | 1249 | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | ||
| @@ -1262,6 +1267,17 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |||||
| 1262 | return i.extensionChromeLinesAtLocked(cols, false) | 1267 | return i.extensionChromeLinesAtLocked(cols, false) | ||
| 1263 | } | 1268 | } | ||
| 1264 | 1269 | ||||
| 1270 | + | func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string { | |||
| 1271 | + | lines := i.extensionChromeLinesAtLocked(cols, rightBarActive) | |||
| 1272 | + | if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows { | |||
| 1273 | + | return lines | |||
| 1274 | + | } | |||
| 1275 | + | limit := maxNarrowExtensionChromeRows | |||
| 1276 | + | trimmed := append([]string(nil), lines[:limit-1]...) | |||
| 1277 | + | trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols))) | |||
| 1278 | + | return trimmed | |||
| 1279 | + | } | |||
| 1280 | + | ||||
| 1265 | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | 1281 | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | ||
| 1266 | var out []string | 1282 | var out []string | ||
| 1267 | bodyWidth := cols - 2 | 1283 | bodyWidth := cols - 2 | ||
| @@ -1595,13 +1611,14 @@ func (i *Interactive) redraw() { | |||||
| 1595 | rightBarWidgets := i.rightBarWidgetsLocked() | 1611 | rightBarWidgets := i.rightBarWidgetsLocked() | ||
| 1596 | rightBarActive := false | 1612 | rightBarActive := false | ||
| 1597 | var rightBarWidth int | 1613 | var rightBarWidth int | ||
| 1598 | - | if len(rightBarWidgets) > 0 { | 1614 | + | if !i.rightBarHidden && len(rightBarWidgets) > 0 { |
| 1599 | if width, rail, ok := tui.RightBarColumns(cols); ok { | 1615 | if width, rail, ok := tui.RightBarColumns(cols); ok { | ||
| 1600 | mainCols = width | 1616 | mainCols = width | ||
| 1601 | rightBarWidth = rail | 1617 | rightBarWidth = rail | ||
| 1602 | rightBarActive = true | 1618 | rightBarActive = true | ||
| 1603 | } | 1619 | } | ||
| 1604 | } | 1620 | } | ||
| 1621 | + | rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive | |||
| 1605 | chat := i.cachedChatLocked(mainCols) | 1622 | chat := i.cachedChatLocked(mainCols) | ||
| 1606 | 1623 | ||||
| 1607 | // Dialogs (login or model picker) render between chat and the editor. | 1624 | // Dialogs (login or model picker) render between chat and the editor. | ||
| @@ -1838,7 +1855,7 @@ func (i *Interactive) redraw() { | |||||
| 1838 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | 1855 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | ||
| 1839 | } | 1856 | } | ||
| 1840 | 1857 | ||||
| 1841 | - | extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive) | 1858 | + | extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback) |
| 1842 | 1859 | ||||
| 1843 | // Bottom-sticky sections (always visible, never scroll). Each | 1860 | // Bottom-sticky sections (always visible, never scroll). Each | ||
| 1844 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | 1861 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | ||
| @@ -2925,6 +2942,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2925 | if i.ed.IsEmpty() && !i.busy { | 2942 | if i.ed.IsEmpty() && !i.busy { | ||
| 2926 | return true | 2943 | return true | ||
| 2927 | } | 2944 | } | ||
| 2945 | + | case tui.KeyCtrlB: | |||
| 2946 | + | i.mu.Lock() | |||
| 2947 | + | i.rightBarHidden = !i.rightBarHidden | |||
| 2948 | + | i.mu.Unlock() | |||
| 2949 | + | i.rend.Invalidate() | |||
| 2950 | + | i.invalidate() | |||
| 2951 | + | return false | |||
| 2928 | case tui.KeyCtrlL: | 2952 | case tui.KeyCtrlL: | ||
| 2929 | i.rend.Clear() | 2953 | i.rend.Clear() | ||
| 2930 | i.invalidate() | 2954 | i.invalidate() | ||
M packages/core/title.go +3 -0
| @@ -76,6 +76,9 @@ func GenerateSessionTitle(ctx context.Context, client provider.Client, model, pr | |||
| 76 | 76 | return "", fmt.Errorf("title generation stream: %w", finalErr) | |
| 77 | 77 | } | |
| 78 | 78 | if !done { | |
| 79 | + | if err := ctx.Err(); err != nil { | |
| 80 | + | return "", fmt.Errorf("title generation stream: %w", err) | |
| 81 | + | } | |
| 79 | 82 | return "", fmt.Errorf("title generation stream ended without a result") | |
| 80 | 83 | } | |
| 81 | 84 | if text.Len() == 0 { | |
| @@ -76,6 +76,9 @@ func GenerateSessionTitle(ctx context.Context, client provider.Client, model, pr | |||||
| 76 | return "", fmt.Errorf("title generation stream: %w", finalErr) | 76 | return "", fmt.Errorf("title generation stream: %w", finalErr) | ||
| 77 | } | 77 | } | ||
| 78 | if !done { | 78 | if !done { | ||
| 79 | + | if err := ctx.Err(); err != nil { | |||
| 80 | + | return "", fmt.Errorf("title generation stream: %w", err) | |||
| 81 | + | } | |||
| 79 | return "", fmt.Errorf("title generation stream ended without a result") | 82 | return "", fmt.Errorf("title generation stream ended without a result") | ||
| 80 | } | 83 | } | ||
| 81 | if text.Len() == 0 { | 84 | if text.Len() == 0 { | ||
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/render.go +12 -7
| @@ -37,12 +37,13 @@ type Renderer struct { | |||
| 37 | 37 | // in the terminal's visible viewport so we can diff safely, and bail | |
| 38 | 38 | // out to clear+replay when the diff would touch rows that are no | |
| 39 | 39 | // longer addressable. | |
| 40 | - | logChat []string | |
| 41 | - | logBottom []string | |
| 42 | - | logLines []string | |
| 43 | - | logViewportTop int | |
| 44 | - | logHardwareRow int | |
| 45 | - | logInit bool | |
| 40 | + | logChat []string | |
| 41 | + | logBottom []string | |
| 42 | + | logLines []string | |
| 43 | + | logViewportTop int | |
| 44 | + | logHardwareRow int | |
| 45 | + | logInit bool | |
| 46 | + | logNeedsFullRepaint bool | |
| 46 | 47 | ||
| 47 | 48 | // keepScrollback is true when we must NOT emit \x1b[3J | |
| 48 | 49 | // (erase-in-display 3, "clear scrollback"). | |
| @@ -588,8 +589,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 588 | 589 | wasInitialized := r.logInit | |
| 589 | 590 | full := !wasInitialized || len(r.logLines) == 0 | |
| 590 | 591 | if full { | |
| 591 | - | writeFull(true, !wasInitialized) | |
| 592 | + | // Returning from the fixed right-bar frame needs a full viewport | |
| 593 | + | // repaint, but it must not discard terminal scrollback merely because | |
| 594 | + | // the renderer's flow cache is intentionally empty. | |
| 595 | + | writeFull(true, !wasInitialized && !r.logNeedsFullRepaint) | |
| 592 | 596 | r.logInit = true | |
| 597 | + | r.logNeedsFullRepaint = false | |
| 593 | 598 | } else { | |
| 594 | 599 | firstChanged := -1 | |
| 595 | 600 | lastChanged := -1 | |
| @@ -37,12 +37,13 @@ type Renderer struct { | |||||
| 37 | // in the terminal's visible viewport so we can diff safely, and bail | 37 | // in the terminal's visible viewport so we can diff safely, and bail | ||
| 38 | // out to clear+replay when the diff would touch rows that are no | 38 | // out to clear+replay when the diff would touch rows that are no | ||
| 39 | // longer addressable. | 39 | // longer addressable. | ||
| 40 | - | logChat []string | 40 | + | logChat []string |
| 41 | - | logBottom []string | 41 | + | logBottom []string |
| 42 | - | logLines []string | 42 | + | logLines []string |
| 43 | - | logViewportTop int | 43 | + | logViewportTop int |
| 44 | - | logHardwareRow int | 44 | + | logHardwareRow int |
| 45 | - | logInit bool | 45 | + | logInit bool |
| 46 | + | logNeedsFullRepaint bool | |||
| 46 | 47 | ||||
| 47 | // keepScrollback is true when we must NOT emit \x1b[3J | 48 | // keepScrollback is true when we must NOT emit \x1b[3J | ||
| 48 | // (erase-in-display 3, "clear scrollback"). | 49 | // (erase-in-display 3, "clear scrollback"). | ||
| @@ -588,8 +589,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 588 | wasInitialized := r.logInit | 589 | wasInitialized := r.logInit | ||
| 589 | full := !wasInitialized || len(r.logLines) == 0 | 590 | full := !wasInitialized || len(r.logLines) == 0 | ||
| 590 | if full { | 591 | if full { | ||
| 591 | - | writeFull(true, !wasInitialized) | 592 | + | // Returning from the fixed right-bar frame needs a full viewport |
| 593 | + | // repaint, but it must not discard terminal scrollback merely because | |||
| 594 | + | // the renderer's flow cache is intentionally empty. | |||
| 595 | + | writeFull(true, !wasInitialized && !r.logNeedsFullRepaint) | |||
| 592 | r.logInit = true | 596 | r.logInit = true | ||
| 597 | + | r.logNeedsFullRepaint = false | |||
| 593 | } else { | 598 | } else { | ||
| 594 | firstChanged := -1 | 599 | firstChanged := -1 | ||
| 595 | lastChanged := -1 | 600 | lastChanged := -1 | ||
M packages/tui/rightbar.go +232 -57
| @@ -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 lines are clipped to the rail width with | |
| 64 | + | // a three-dot ellipsis. | |
| 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,89 @@ 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, phase bool) string { | |
| 81 | + | if phase { | |
| 82 | + | text = truncateRightBarPhaseLine(text, width) | |
| 83 | + | } else { | |
| 84 | + | text = truncateRightBarLine(text, width) | |
| 85 | + | } | |
| 86 | + | if visible := visibleWidth(text); visible < width { | |
| 87 | + | text += strings.Repeat(" ", width-visible) | |
| 88 | + | } | |
| 89 | + | return text | |
| 90 | + | } | |
| 91 | + | paintLine := func(text string, color int, dim, phase bool) string { | |
| 92 | + | text = padLine(text, phase) | |
| 93 | + | if color < 0 { | |
| 94 | + | return text | |
| 95 | + | } | |
| 96 | + | paint := func(value string, valueColor int) string { | |
| 97 | + | if dim { | |
| 98 | + | value = Dim(value) | |
| 99 | + | } | |
| 100 | + | return th.FG256(valueColor, value) | |
| 101 | + | } | |
| 102 | + | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | |
| 103 | + | if phase { | |
| 104 | + | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | |
| 105 | + | return paint(text[:markerStart], color) + | |
| 106 | + | paint(text[markerStart:markerEnd], th.Muted) + | |
| 107 | + | paint(text[markerEnd:nameStart], color) + | |
| 108 | + | paint(Bold(text[nameStart:nameEnd]), color) + | |
| 109 | + | paint(text[nameEnd:], color) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return paint(text[:markerStart], color) + | |
| 113 | + | paint(text[markerStart:markerEnd], th.Muted) + | |
| 114 | + | paint(text[markerEnd:], color) | |
| 102 | 115 | } | |
| 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) | |
| 116 | + | if phase { | |
| 117 | + | return paint(Bold(text), color) | |
| 107 | 118 | } | |
| 108 | - | if color >= 0 { | |
| 109 | - | text = th.FG256(color, text) | |
| 119 | + | return paint(text, color) | |
| 120 | + | } | |
| 121 | + | appendContent := func(text string, color int, dim, phase bool) bool { | |
| 122 | + | text = strings.ReplaceAll(text, "\r", "") | |
| 123 | + | for _, line := range strings.Split(text, "\n") { | |
| 124 | + | if len(content) >= height { | |
| 125 | + | truncated = true | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | content = append(content, paintLine(line, color, dim, phase)) | |
| 110 | 129 | } | |
| 111 | - | content = append(content, text) | |
| 112 | 130 | return true | |
| 113 | 131 | } | |
| 114 | 132 | ||
| 115 | 133 | for idx, widget := range ordered { | |
| 116 | - | if idx > 0 && !appendContent("", -1) { | |
| 134 | + | if idx > 0 && !appendContent("", -1, false, false) { | |
| 117 | 135 | break | |
| 118 | 136 | } | |
| 119 | 137 | label := "[" + widget.Extension + "]" | |
| 120 | 138 | if widget.Title != "" { | |
| 121 | 139 | label += " " + widget.Title | |
| 122 | 140 | } | |
| 123 | - | if !appendContent(label, th.Accent) { | |
| 141 | + | if !appendContent(label, th.Accent, false, false) { | |
| 124 | 142 | break | |
| 125 | 143 | } | |
| 144 | + | if !appendContent("", -1, false, false) { | |
| 145 | + | break | |
| 146 | + | } | |
| 147 | + | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | |
| 148 | + | if activePhaseIndent >= 0 { | |
| 149 | + | activePhaseIndent++ // host-owned line prefix | |
| 150 | + | } | |
| 151 | + | phaseIndent := rightBarChecklistIndent(widget.Lines) | |
| 152 | + | if phaseIndent >= 0 { | |
| 153 | + | phaseIndent++ // host-owned line prefix | |
| 154 | + | } | |
| 155 | + | activePhase := false | |
| 126 | 156 | for _, line := range widget.Lines { | |
| 127 | - | if !appendContent(" "+line, th.FG) { | |
| 157 | + | line = " " + line | |
| 158 | + | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | |
| 159 | + | phase := rightBarChecklistPhaseLine(line, phaseIndent) | |
| 160 | + | if !appendContent(line, th.FG, dim, phase) { | |
| 128 | 161 | break | |
| 129 | 162 | } | |
| 130 | 163 | } | |
| @@ -133,30 +166,170 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 133 | 166 | } | |
| 134 | 167 | } | |
| 135 | 168 | if truncated { | |
| 136 | - | marker := "... right bar ..." | |
| 137 | - | marker = truncateToWidth(marker, innerWidth) | |
| 138 | - | if len(content) == contentRows { | |
| 139 | - | content[len(content)-1] = "" | |
| 169 | + | marker := paintLine("... right bar ...", th.Muted, false, false) | |
| 170 | + | if len(content) == height { | |
| 171 | + | content[len(content)-1] = marker | |
| 172 | + | } else { | |
| 173 | + | content = append(content, marker) | |
| 140 | 174 | } | |
| 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) | |
| 175 | + | } | |
| 176 | + | for len(content) < height { | |
| 177 | + | content = append(content, strings.Repeat(" ", width)) | |
| 178 | + | } | |
| 179 | + | return content | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) { | |
| 183 | + | start = 0 | |
| 184 | + | for start < len(text) && text[start] == ' ' { | |
| 185 | + | start++ | |
| 186 | + | } | |
| 187 | + | if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' { | |
| 188 | + | return 0, 0, 0, false | |
| 189 | + | } | |
| 190 | + | switch text[start+1] { | |
| 191 | + | case ' ', 'x', 'X', '>': | |
| 192 | + | return start, start + 3, text[start+1], true | |
| 193 | + | default: | |
| 194 | + | return 0, 0, 0, false | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func truncateRightBarLine(text string, width int) string { | |
| 199 | + | if width <= 0 || visibleWidth(text) <= width { | |
| 200 | + | return text | |
| 201 | + | } | |
| 202 | + | if width <= 3 { | |
| 203 | + | return truncateToWidth("...", width) | |
| 204 | + | } | |
| 205 | + | return truncateToWidth(text, width-3) + "..." | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func truncateRightBarPhaseLine(text string, width int) string { | |
| 209 | + | if width <= 0 || visibleWidth(text) <= width { | |
| 210 | + | return text | |
| 211 | + | } | |
| 212 | + | nameStart, nameEnd, ok := rightBarChecklistPhaseName(text) | |
| 213 | + | if !ok { | |
| 214 | + | return truncateRightBarLine(text, width) | |
| 215 | + | } | |
| 216 | + | prefix := text[:nameStart] | |
| 217 | + | suffix := text[nameEnd:] | |
| 218 | + | nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix) | |
| 219 | + | if nameWidth <= 0 { | |
| 220 | + | return truncateRightBarLine(text, width) | |
| 221 | + | } | |
| 222 | + | return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func rightBarChecklistIndent(lines []string) int { | |
| 226 | + | indent := -1 | |
| 227 | + | for _, line := range lines { | |
| 228 | + | start, _, _, ok := rightBarChecklistMarker(line) | |
| 229 | + | if ok && (indent < 0 || start < indent) { | |
| 230 | + | indent = start | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | return indent | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func rightBarChecklistPhaseLine(text string, phaseIndent int) bool { | |
| 237 | + | if phaseIndent < 0 { | |
| 238 | + | return false | |
| 239 | + | } | |
| 240 | + | start, _, _, ok := rightBarChecklistMarker(text) | |
| 241 | + | return ok && start == phaseIndent | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func rightBarChecklistPhaseName(text string) (start, end int, ok bool) { | |
| 245 | + | _, markerEnd, _, markerOK := rightBarChecklistMarker(text) | |
| 246 | + | if !markerOK { | |
| 247 | + | return 0, 0, false | |
| 248 | + | } | |
| 249 | + | start = markerEnd | |
| 250 | + | if start < len(text) && text[start] == ' ' { | |
| 251 | + | start++ | |
| 252 | + | } | |
| 253 | + | trimmedEnd := len(strings.TrimRight(text, " ")) | |
| 254 | + | if trimmedEnd <= start { | |
| 255 | + | return 0, 0, false | |
| 256 | + | } | |
| 257 | + | suffix := text[start:trimmedEnd] | |
| 258 | + | separator := strings.LastIndex(suffix, " ") | |
| 259 | + | if separator < 0 { | |
| 260 | + | return rightBarFallbackPhaseName(text, start, trimmedEnd) | |
| 261 | + | } | |
| 262 | + | nameEnd := start + separator | |
| 263 | + | progress := strings.TrimSpace(text[nameEnd:trimmedEnd]) | |
| 264 | + | if !rightBarProgressToken(progress) { | |
| 265 | + | return rightBarFallbackPhaseName(text, start, trimmedEnd) | |
| 266 | + | } | |
| 267 | + | for nameEnd > start && text[nameEnd-1] == ' ' { | |
| 268 | + | nameEnd-- | |
| 269 | + | } | |
| 270 | + | if nameEnd <= start { | |
| 271 | + | return 0, 0, false | |
| 272 | + | } | |
| 273 | + | return start, nameEnd, true | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) { | |
| 277 | + | for end > start && text[end-1] == ' ' { | |
| 278 | + | end-- | |
| 279 | + | } | |
| 280 | + | if end-start >= 3 && text[end-3:end] == "..." { | |
| 281 | + | end -= 3 | |
| 282 | + | } | |
| 283 | + | for end > start && text[end-1] == ' ' { | |
| 284 | + | end-- | |
| 285 | + | } | |
| 286 | + | if end <= start { | |
| 287 | + | return 0, 0, false | |
| 288 | + | } | |
| 289 | + | return start, end, true | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func rightBarProgressToken(value string) bool { | |
| 293 | + | separator := strings.IndexByte(value, '/') | |
| 294 | + | if separator <= 0 || separator == len(value)-1 { | |
| 295 | + | return false | |
| 296 | + | } | |
| 297 | + | for _, part := range []string{value[:separator], value[separator+1:]} { | |
| 298 | + | for _, r := range part { | |
| 299 | + | if r < '0' || r > '9' { | |
| 300 | + | return false | |
| 301 | + | } | |
| 146 | 302 | } | |
| 147 | 303 | } | |
| 304 | + | return true | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func rightBarActivePhaseIndent(lines []string) int { | |
| 308 | + | for _, line := range lines { | |
| 309 | + | start, _, marker, ok := rightBarChecklistMarker(line) | |
| 310 | + | if ok && marker == '>' { | |
| 311 | + | return start | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | return -1 | |
| 315 | + | } | |
| 148 | 316 | ||
| 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("│")) | |
| 317 | + | func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool { | |
| 318 | + | if activePhaseIndent < 0 { | |
| 319 | + | return false | |
| 320 | + | } | |
| 321 | + | start, _, marker, ok := rightBarChecklistMarker(text) | |
| 322 | + | if !ok { | |
| 323 | + | return false | |
| 324 | + | } | |
| 325 | + | if start == activePhaseIndent { | |
| 326 | + | *activePhase = marker == '>' | |
| 327 | + | return !*activePhase | |
| 154 | 328 | } | |
| 155 | - | for len(out) < height-1 { | |
| 156 | - | out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│")) | |
| 329 | + | if start > activePhaseIndent { | |
| 330 | + | return !*activePhase | |
| 157 | 331 | } | |
| 158 | - | out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘")) | |
| 159 | - | return out | |
| 332 | + | return false | |
| 160 | 333 | } | |
| 161 | 334 | ||
| 162 | 335 | // JoinRightBar combines one main-pane row and one right-bar row without | |
| @@ -228,12 +401,14 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||
| 228 | 401 | ||
| 229 | 402 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | |
| 230 | 403 | // later fallback to DrawLog starts with a full repaint instead of trying | |
| 231 | - | // to diff against logical rows from before the rail was enabled. | |
| 404 | + | // to diff against logical rows from before the rail was enabled. Mark the | |
| 405 | + | // transition separately so that repaint does not clear scrollback. | |
| 232 | 406 | r.logChat = nil | |
| 233 | 407 | r.logBottom = nil | |
| 234 | 408 | r.logLines = nil | |
| 235 | 409 | r.logViewportTop = 0 | |
| 236 | 410 | r.logHardwareRow = 0 | |
| 237 | 411 | r.logInit = false | |
| 412 | + | r.logNeedsFullRepaint = true | |
| 238 | 413 | r.Draw(frame, cursorRow, cursorCol) | |
| 239 | 414 | } | |
| @@ -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 lines are clipped to the rail width with |
| 64 | + | // a three-dot ellipsis. | |||
| 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,89 @@ 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, phase bool) string { |
| 99 | - | if len(content) >= contentRows { | 81 | + | if phase { |
| 100 | - | truncated = true | 82 | + | text = truncateRightBarPhaseLine(text, width) |
| 101 | - | return false | 83 | + | } else { |
| 84 | + | text = truncateRightBarLine(text, width) | |||
| 85 | + | } | |||
| 86 | + | if visible := visibleWidth(text); visible < width { | |||
| 87 | + | text += strings.Repeat(" ", width-visible) | |||
| 88 | + | } | |||
| 89 | + | return text | |||
| 90 | + | } | |||
| 91 | + | paintLine := func(text string, color int, dim, phase bool) string { | |||
| 92 | + | text = padLine(text, phase) | |||
| 93 | + | if color < 0 { | |||
| 94 | + | return text | |||
| 95 | + | } | |||
| 96 | + | paint := func(value string, valueColor int) string { | |||
| 97 | + | if dim { | |||
| 98 | + | value = Dim(value) | |||
| 99 | + | } | |||
| 100 | + | return th.FG256(valueColor, value) | |||
| 101 | + | } | |||
| 102 | + | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | |||
| 103 | + | if phase { | |||
| 104 | + | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | |||
| 105 | + | return paint(text[:markerStart], color) + | |||
| 106 | + | paint(text[markerStart:markerEnd], th.Muted) + | |||
| 107 | + | paint(text[markerEnd:nameStart], color) + | |||
| 108 | + | paint(Bold(text[nameStart:nameEnd]), color) + | |||
| 109 | + | paint(text[nameEnd:], color) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return paint(text[:markerStart], color) + | |||
| 113 | + | paint(text[markerStart:markerEnd], th.Muted) + | |||
| 114 | + | paint(text[markerEnd:], color) | |||
| 102 | } | 115 | } | ||
| 103 | - | text = strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ↩ ") | 116 | + | if phase { |
| 104 | - | text = truncateToWidth(text, innerWidth) | 117 | + | return paint(Bold(text), color) |
| 105 | - | if visible := visibleWidth(text); visible < innerWidth { | |||
| 106 | - | text += strings.Repeat(" ", innerWidth-visible) | |||
| 107 | } | 118 | } | ||
| 108 | - | if color >= 0 { | 119 | + | return paint(text, color) |
| 109 | - | text = th.FG256(color, text) | 120 | + | } |
| 121 | + | appendContent := func(text string, color int, dim, phase bool) bool { | |||
| 122 | + | text = strings.ReplaceAll(text, "\r", "") | |||
| 123 | + | for _, line := range strings.Split(text, "\n") { | |||
| 124 | + | if len(content) >= height { | |||
| 125 | + | truncated = true | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | content = append(content, paintLine(line, color, dim, phase)) | |||
| 110 | } | 129 | } | ||
| 111 | - | content = append(content, text) | |||
| 112 | return true | 130 | return true | ||
| 113 | } | 131 | } | ||
| 114 | 132 | ||||
| 115 | for idx, widget := range ordered { | 133 | for idx, widget := range ordered { | ||
| 116 | - | if idx > 0 && !appendContent("", -1) { | 134 | + | if idx > 0 && !appendContent("", -1, false, false) { |
| 117 | break | 135 | break | ||
| 118 | } | 136 | } | ||
| 119 | label := "[" + widget.Extension + "]" | 137 | label := "[" + widget.Extension + "]" | ||
| 120 | if widget.Title != "" { | 138 | if widget.Title != "" { | ||
| 121 | label += " " + widget.Title | 139 | label += " " + widget.Title | ||
| 122 | } | 140 | } | ||
| 123 | - | if !appendContent(label, th.Accent) { | 141 | + | if !appendContent(label, th.Accent, false, false) { |
| 124 | break | 142 | break | ||
| 125 | } | 143 | } | ||
| 144 | + | if !appendContent("", -1, false, false) { | |||
| 145 | + | break | |||
| 146 | + | } | |||
| 147 | + | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | |||
| 148 | + | if activePhaseIndent >= 0 { | |||
| 149 | + | activePhaseIndent++ // host-owned line prefix | |||
| 150 | + | } | |||
| 151 | + | phaseIndent := rightBarChecklistIndent(widget.Lines) | |||
| 152 | + | if phaseIndent >= 0 { | |||
| 153 | + | phaseIndent++ // host-owned line prefix | |||
| 154 | + | } | |||
| 155 | + | activePhase := false | |||
| 126 | for _, line := range widget.Lines { | 156 | for _, line := range widget.Lines { | ||
| 127 | - | if !appendContent(" "+line, th.FG) { | 157 | + | line = " " + line |
| 158 | + | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | |||
| 159 | + | phase := rightBarChecklistPhaseLine(line, phaseIndent) | |||
| 160 | + | if !appendContent(line, th.FG, dim, phase) { | |||
| 128 | break | 161 | break | ||
| 129 | } | 162 | } | ||
| 130 | } | 163 | } | ||
| @@ -133,30 +166,170 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||||
| 133 | } | 166 | } | ||
| 134 | } | 167 | } | ||
| 135 | if truncated { | 168 | if truncated { | ||
| 136 | - | marker := "... right bar ..." | 169 | + | marker := paintLine("... right bar ...", th.Muted, false, false) |
| 137 | - | marker = truncateToWidth(marker, innerWidth) | 170 | + | if len(content) == height { |
| 138 | - | if len(content) == contentRows { | 171 | + | content[len(content)-1] = marker |
| 139 | - | content[len(content)-1] = "" | 172 | + | } else { |
| 173 | + | content = append(content, marker) | |||
| 140 | } | 174 | } | ||
| 141 | - | if !appendContent(marker, th.Muted) && len(content) > 0 { | 175 | + | } |
| 142 | - | // appendContent cannot append when the row budget is full, so | 176 | + | for len(content) < height { |
| 143 | - | // replace the final row with the already-padded marker instead. | 177 | + | content = append(content, strings.Repeat(" ", width)) |
| 144 | - | marker = marker + strings.Repeat(" ", max(0, innerWidth-visibleWidth(marker))) | 178 | + | } |
| 145 | - | content[len(content)-1] = th.FG256(th.Muted, marker) | 179 | + | return content |
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) { | |||
| 183 | + | start = 0 | |||
| 184 | + | for start < len(text) && text[start] == ' ' { | |||
| 185 | + | start++ | |||
| 186 | + | } | |||
| 187 | + | if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' { | |||
| 188 | + | return 0, 0, 0, false | |||
| 189 | + | } | |||
| 190 | + | switch text[start+1] { | |||
| 191 | + | case ' ', 'x', 'X', '>': | |||
| 192 | + | return start, start + 3, text[start+1], true | |||
| 193 | + | default: | |||
| 194 | + | return 0, 0, 0, false | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func truncateRightBarLine(text string, width int) string { | |||
| 199 | + | if width <= 0 || visibleWidth(text) <= width { | |||
| 200 | + | return text | |||
| 201 | + | } | |||
| 202 | + | if width <= 3 { | |||
| 203 | + | return truncateToWidth("...", width) | |||
| 204 | + | } | |||
| 205 | + | return truncateToWidth(text, width-3) + "..." | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func truncateRightBarPhaseLine(text string, width int) string { | |||
| 209 | + | if width <= 0 || visibleWidth(text) <= width { | |||
| 210 | + | return text | |||
| 211 | + | } | |||
| 212 | + | nameStart, nameEnd, ok := rightBarChecklistPhaseName(text) | |||
| 213 | + | if !ok { | |||
| 214 | + | return truncateRightBarLine(text, width) | |||
| 215 | + | } | |||
| 216 | + | prefix := text[:nameStart] | |||
| 217 | + | suffix := text[nameEnd:] | |||
| 218 | + | nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix) | |||
| 219 | + | if nameWidth <= 0 { | |||
| 220 | + | return truncateRightBarLine(text, width) | |||
| 221 | + | } | |||
| 222 | + | return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func rightBarChecklistIndent(lines []string) int { | |||
| 226 | + | indent := -1 | |||
| 227 | + | for _, line := range lines { | |||
| 228 | + | start, _, _, ok := rightBarChecklistMarker(line) | |||
| 229 | + | if ok && (indent < 0 || start < indent) { | |||
| 230 | + | indent = start | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | return indent | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func rightBarChecklistPhaseLine(text string, phaseIndent int) bool { | |||
| 237 | + | if phaseIndent < 0 { | |||
| 238 | + | return false | |||
| 239 | + | } | |||
| 240 | + | start, _, _, ok := rightBarChecklistMarker(text) | |||
| 241 | + | return ok && start == phaseIndent | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func rightBarChecklistPhaseName(text string) (start, end int, ok bool) { | |||
| 245 | + | _, markerEnd, _, markerOK := rightBarChecklistMarker(text) | |||
| 246 | + | if !markerOK { | |||
| 247 | + | return 0, 0, false | |||
| 248 | + | } | |||
| 249 | + | start = markerEnd | |||
| 250 | + | if start < len(text) && text[start] == ' ' { | |||
| 251 | + | start++ | |||
| 252 | + | } | |||
| 253 | + | trimmedEnd := len(strings.TrimRight(text, " ")) | |||
| 254 | + | if trimmedEnd <= start { | |||
| 255 | + | return 0, 0, false | |||
| 256 | + | } | |||
| 257 | + | suffix := text[start:trimmedEnd] | |||
| 258 | + | separator := strings.LastIndex(suffix, " ") | |||
| 259 | + | if separator < 0 { | |||
| 260 | + | return rightBarFallbackPhaseName(text, start, trimmedEnd) | |||
| 261 | + | } | |||
| 262 | + | nameEnd := start + separator | |||
| 263 | + | progress := strings.TrimSpace(text[nameEnd:trimmedEnd]) | |||
| 264 | + | if !rightBarProgressToken(progress) { | |||
| 265 | + | return rightBarFallbackPhaseName(text, start, trimmedEnd) | |||
| 266 | + | } | |||
| 267 | + | for nameEnd > start && text[nameEnd-1] == ' ' { | |||
| 268 | + | nameEnd-- | |||
| 269 | + | } | |||
| 270 | + | if nameEnd <= start { | |||
| 271 | + | return 0, 0, false | |||
| 272 | + | } | |||
| 273 | + | return start, nameEnd, true | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) { | |||
| 277 | + | for end > start && text[end-1] == ' ' { | |||
| 278 | + | end-- | |||
| 279 | + | } | |||
| 280 | + | if end-start >= 3 && text[end-3:end] == "..." { | |||
| 281 | + | end -= 3 | |||
| 282 | + | } | |||
| 283 | + | for end > start && text[end-1] == ' ' { | |||
| 284 | + | end-- | |||
| 285 | + | } | |||
| 286 | + | if end <= start { | |||
| 287 | + | return 0, 0, false | |||
| 288 | + | } | |||
| 289 | + | return start, end, true | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func rightBarProgressToken(value string) bool { | |||
| 293 | + | separator := strings.IndexByte(value, '/') | |||
| 294 | + | if separator <= 0 || separator == len(value)-1 { | |||
| 295 | + | return false | |||
| 296 | + | } | |||
| 297 | + | for _, part := range []string{value[:separator], value[separator+1:]} { | |||
| 298 | + | for _, r := range part { | |||
| 299 | + | if r < '0' || r > '9' { | |||
| 300 | + | return false | |||
| 301 | + | } | |||
| 146 | } | 302 | } | ||
| 147 | } | 303 | } | ||
| 304 | + | return true | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func rightBarActivePhaseIndent(lines []string) int { | |||
| 308 | + | for _, line := range lines { | |||
| 309 | + | start, _, marker, ok := rightBarChecklistMarker(line) | |||
| 310 | + | if ok && marker == '>' { | |||
| 311 | + | return start | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | return -1 | |||
| 315 | + | } | |||
| 148 | 316 | ||||
| 149 | - | border := func(glyph string) string { return th.FG256(th.Muted, glyph) } | 317 | + | func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool { |
| 150 | - | out := make([]string, 0, height) | 318 | + | if activePhaseIndent < 0 { |
| 151 | - | out = append(out, border("┌")+strings.Repeat("─", innerWidth)+border("┐")) | 319 | + | return false |
| 152 | - | for _, line := range content { | 320 | + | } |
| 153 | - | out = append(out, border("│")+line+border("│")) | 321 | + | start, _, marker, ok := rightBarChecklistMarker(text) |
| 322 | + | if !ok { | |||
| 323 | + | return false | |||
| 324 | + | } | |||
| 325 | + | if start == activePhaseIndent { | |||
| 326 | + | *activePhase = marker == '>' | |||
| 327 | + | return !*activePhase | |||
| 154 | } | 328 | } | ||
| 155 | - | for len(out) < height-1 { | 329 | + | if start > activePhaseIndent { |
| 156 | - | out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│")) | 330 | + | return !*activePhase |
| 157 | } | 331 | } | ||
| 158 | - | out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘")) | 332 | + | return false |
| 159 | - | return out | |||
| 160 | } | 333 | } | ||
| 161 | 334 | ||||
| 162 | // JoinRightBar combines one main-pane row and one right-bar row without | 335 | // JoinRightBar combines one main-pane row and one right-bar row without | ||
| @@ -228,12 +401,14 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||||
| 228 | 401 | ||||
| 229 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | 402 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | ||
| 230 | // later fallback to DrawLog starts with a full repaint instead of trying | 403 | // later fallback to DrawLog starts with a full repaint instead of trying | ||
| 231 | - | // to diff against logical rows from before the rail was enabled. | 404 | + | // to diff against logical rows from before the rail was enabled. Mark the |
| 405 | + | // transition separately so that repaint does not clear scrollback. | |||
| 232 | r.logChat = nil | 406 | r.logChat = nil | ||
| 233 | r.logBottom = nil | 407 | r.logBottom = nil | ||
| 234 | r.logLines = nil | 408 | r.logLines = nil | ||
| 235 | r.logViewportTop = 0 | 409 | r.logViewportTop = 0 | ||
| 236 | r.logHardwareRow = 0 | 410 | r.logHardwareRow = 0 | ||
| 237 | r.logInit = false | 411 | r.logInit = false | ||
| 412 | + | r.logNeedsFullRepaint = true | |||
| 238 | r.Draw(frame, cursorRow, cursorCol) | 413 | r.Draw(frame, cursorRow, cursorCol) | ||
| 239 | } | 414 | } | ||
M packages/tui/rightbar_test.go +157 -3
| @@ -31,23 +31,152 @@ 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 TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) { | |
| 55 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 56 | + | Extension: "plan", | |
| 57 | + | Lines: []string{"one two three four five six"}, | |
| 58 | + | }}, 16, 8) | |
| 59 | + | ||
| 60 | + | if len(lines) != 8 { | |
| 61 | + | t.Fatalf("right bar rows = %d, want 8", len(lines)) | |
| 62 | + | } | |
| 63 | + | plain := stripANSI(strings.Join(lines, "\n")) | |
| 64 | + | if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") { | |
| 65 | + | t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain) | |
| 66 | + | } | |
| 67 | + | for idx, line := range lines { | |
| 68 | + | if width := visibleWidth(line); width != 16 { | |
| 69 | + | t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | |
| 75 | + | lines := RenderRightBar(Dark, []RightBarWidget{ | |
| 76 | + | { | |
| 77 | + | Extension: "tasked-phases", | |
| 78 | + | Lines: []string{"[ ] A phase title that is much too long to fit 0/3"}, | |
| 79 | + | }, | |
| 80 | + | }, 24, 5) | |
| 81 | + | plainLines := strings.Split(stripANSI(strings.Join(lines, "\n")), "\n") | |
| 82 | + | if len(plainLines) < 3 { | |
| 83 | + | t.Fatalf("phase row is missing: %q", plainLines) | |
| 84 | + | } | |
| 85 | + | phase := strings.TrimRight(plainLines[2], " ") | |
| 86 | + | if !strings.HasSuffix(phase, "0/3") || !strings.Contains(phase, "... 0/3") { | |
| 87 | + | t.Fatalf("phase progress was clipped with its title: %q", phase) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | |
| 92 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 93 | + | Extension: "tasked-phases", | |
| 94 | + | Title: "p 0/1 | t 0/1", | |
| 95 | + | Lines: []string{ | |
| 96 | + | "[>] Copper Lantern Setup 0/1", | |
| 97 | + | " [ ] Sort the leftover star stickers", | |
| 98 | + | }, | |
| 99 | + | }}, 36, 8) | |
| 100 | + | plain := stripANSI(strings.Join(lines, "\n")) | |
| 101 | + | if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") { | |
| 102 | + | t.Fatalf("phase row has the wrong indentation: %q", plain) | |
| 103 | + | } | |
| 104 | + | plainLines := strings.Split(plain, "\n") | |
| 105 | + | if len(plainLines) < 4 { | |
| 106 | + | t.Fatalf("checklist rows are missing: %q", plain) | |
| 107 | + | } | |
| 108 | + | expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36) | |
| 109 | + | if got := strings.TrimRight(plainLines[3], " "); got != expectedTask { | |
| 110 | + | t.Fatalf("task row = %q, want clipped row %q", got, expectedTask) | |
| 111 | + | } | |
| 112 | + | if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) { | |
| 113 | + | t.Fatalf("phase name is not bold: %q", lines) | |
| 114 | + | } | |
| 115 | + | if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) { | |
| 116 | + | t.Fatalf("task text should not be bold: %q", lines) | |
| 117 | + | } | |
| 118 | + | if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" { | |
| 119 | + | t.Fatalf("widget title is not followed by an empty row: %q", plain) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestRenderRightBarDimsChecklistMarkers(t *testing.T) { | |
| 124 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 125 | + | Extension: "plan", | |
| 126 | + | Lines: []string{"[x] done", "[>] active", "[ ] pending"}, | |
| 127 | + | }}, 24, 8) | |
| 128 | + | rendered := strings.Join(lines, "\n") | |
| 129 | + | for _, marker := range []string{"[x]", "[>]", "[ ]"} { | |
| 130 | + | if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) && | |
| 131 | + | !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) { | |
| 132 | + | t.Fatalf("marker %q was not muted: %q", marker, rendered) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | if !strings.Contains(stripANSI(rendered), " done") { | |
| 136 | + | t.Fatalf("checklist text was not kept readable: %q", rendered) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) { | |
| 141 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |
| 142 | + | Extension: "plan", | |
| 143 | + | Lines: []string{ | |
| 144 | + | "[x] finished phase", | |
| 145 | + | " [x] finished task", | |
| 146 | + | "[>] active phase", | |
| 147 | + | " [>] active task", | |
| 148 | + | "[ ] pending phase", | |
| 149 | + | " [ ] pending task", | |
| 150 | + | }, | |
| 151 | + | }}, 32, 12) | |
| 152 | + | for _, line := range lines { | |
| 153 | + | if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") { | |
| 154 | + | if strings.Contains(line, "\x1b[2m") { | |
| 155 | + | t.Fatalf("active checklist row was dimmed: %q", line) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | plain := stripANSI(strings.Join(lines, "\n")) | |
| 160 | + | for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} { | |
| 161 | + | if !strings.Contains(plain, text) { | |
| 162 | + | t.Fatalf("inactive checklist text %q missing: %q", text, plain) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") { | |
| 166 | + | t.Fatalf("inactive checklist rows were not dimmed: %q", lines) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) { | |
| 171 | + | line := JoinRightBar(Dark, "main", "right", 8, 8) | |
| 172 | + | if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) { | |
| 173 | + | t.Fatalf("separator is not muted: %q", line) | |
| 174 | + | } | |
| 175 | + | if strings.ContainsAny(stripANSI(line), "┌┐└┘─") { | |
| 176 | + | t.Fatalf("joined right bar added an outer rule: %q", line) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 51 | 180 | func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |
| 52 | 181 | line := JoinRightBar(Dark, "界", "✓ done", 8, 6) | |
| 53 | 182 | if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want { | |
| @@ -55,6 +184,31 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |||
| 55 | 184 | } | |
| 56 | 185 | } | |
| 57 | 186 | ||
| 187 | + | func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | |
| 188 | + | t.Setenv("TERM_PROGRAM", "") | |
| 189 | + | ||
| 190 | + | var out bytes.Buffer | |
| 191 | + | r := NewRenderer(&out) | |
| 192 | + | r.SetTheme(Dark) | |
| 193 | + | r.Resize(80, 5) | |
| 194 | + | r.DrawRightBar( | |
| 195 | + | []string{"chat"}, | |
| 196 | + | []string{"input"}, | |
| 197 | + | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |
| 198 | + | 0, | |
| 199 | + | 0, | |
| 200 | + | ) | |
| 201 | + | ||
| 202 | + | out.Reset() | |
| 203 | + | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | |
| 204 | + | if !strings.Contains(stripANSI(out.String()), "input") { | |
| 205 | + | t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String()) | |
| 206 | + | } | |
| 207 | + | if strings.Contains(out.String(), SeqClearScrollback) { | |
| 208 | + | t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String()) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 58 | 212 | func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | |
| 59 | 213 | var out bytes.Buffer | |
| 60 | 214 | r := NewRenderer(&out) | |
| @@ -31,23 +31,152 @@ 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 TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) { | |||
| 55 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 56 | + | Extension: "plan", | |||
| 57 | + | Lines: []string{"one two three four five six"}, | |||
| 58 | + | }}, 16, 8) | |||
| 59 | + | ||||
| 60 | + | if len(lines) != 8 { | |||
| 61 | + | t.Fatalf("right bar rows = %d, want 8", len(lines)) | |||
| 62 | + | } | |||
| 63 | + | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 64 | + | if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") { | |||
| 65 | + | t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain) | |||
| 66 | + | } | |||
| 67 | + | for idx, line := range lines { | |||
| 68 | + | if width := visibleWidth(line); width != 16 { | |||
| 69 | + | t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) { | |||
| 75 | + | lines := RenderRightBar(Dark, []RightBarWidget{ | |||
| 76 | + | { | |||
| 77 | + | Extension: "tasked-phases", | |||
| 78 | + | Lines: []string{"[ ] A phase title that is much too long to fit 0/3"}, | |||
| 79 | + | }, | |||
| 80 | + | }, 24, 5) | |||
| 81 | + | plainLines := strings.Split(stripANSI(strings.Join(lines, "\n")), "\n") | |||
| 82 | + | if len(plainLines) < 3 { | |||
| 83 | + | t.Fatalf("phase row is missing: %q", plainLines) | |||
| 84 | + | } | |||
| 85 | + | phase := strings.TrimRight(plainLines[2], " ") | |||
| 86 | + | if !strings.HasSuffix(phase, "0/3") || !strings.Contains(phase, "... 0/3") { | |||
| 87 | + | t.Fatalf("phase progress was clipped with its title: %q", phase) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) { | |||
| 92 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 93 | + | Extension: "tasked-phases", | |||
| 94 | + | Title: "p 0/1 | t 0/1", | |||
| 95 | + | Lines: []string{ | |||
| 96 | + | "[>] Copper Lantern Setup 0/1", | |||
| 97 | + | " [ ] Sort the leftover star stickers", | |||
| 98 | + | }, | |||
| 99 | + | }}, 36, 8) | |||
| 100 | + | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 101 | + | if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") { | |||
| 102 | + | t.Fatalf("phase row has the wrong indentation: %q", plain) | |||
| 103 | + | } | |||
| 104 | + | plainLines := strings.Split(plain, "\n") | |||
| 105 | + | if len(plainLines) < 4 { | |||
| 106 | + | t.Fatalf("checklist rows are missing: %q", plain) | |||
| 107 | + | } | |||
| 108 | + | expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36) | |||
| 109 | + | if got := strings.TrimRight(plainLines[3], " "); got != expectedTask { | |||
| 110 | + | t.Fatalf("task row = %q, want clipped row %q", got, expectedTask) | |||
| 111 | + | } | |||
| 112 | + | if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) { | |||
| 113 | + | t.Fatalf("phase name is not bold: %q", lines) | |||
| 114 | + | } | |||
| 115 | + | if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) { | |||
| 116 | + | t.Fatalf("task text should not be bold: %q", lines) | |||
| 117 | + | } | |||
| 118 | + | if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" { | |||
| 119 | + | t.Fatalf("widget title is not followed by an empty row: %q", plain) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestRenderRightBarDimsChecklistMarkers(t *testing.T) { | |||
| 124 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 125 | + | Extension: "plan", | |||
| 126 | + | Lines: []string{"[x] done", "[>] active", "[ ] pending"}, | |||
| 127 | + | }}, 24, 8) | |||
| 128 | + | rendered := strings.Join(lines, "\n") | |||
| 129 | + | for _, marker := range []string{"[x]", "[>]", "[ ]"} { | |||
| 130 | + | if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) && | |||
| 131 | + | !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) { | |||
| 132 | + | t.Fatalf("marker %q was not muted: %q", marker, rendered) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | if !strings.Contains(stripANSI(rendered), " done") { | |||
| 136 | + | t.Fatalf("checklist text was not kept readable: %q", rendered) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) { | |||
| 141 | + | lines := RenderRightBar(Dark, []RightBarWidget{{ | |||
| 142 | + | Extension: "plan", | |||
| 143 | + | Lines: []string{ | |||
| 144 | + | "[x] finished phase", | |||
| 145 | + | " [x] finished task", | |||
| 146 | + | "[>] active phase", | |||
| 147 | + | " [>] active task", | |||
| 148 | + | "[ ] pending phase", | |||
| 149 | + | " [ ] pending task", | |||
| 150 | + | }, | |||
| 151 | + | }}, 32, 12) | |||
| 152 | + | for _, line := range lines { | |||
| 153 | + | if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") { | |||
| 154 | + | if strings.Contains(line, "\x1b[2m") { | |||
| 155 | + | t.Fatalf("active checklist row was dimmed: %q", line) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 160 | + | for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} { | |||
| 161 | + | if !strings.Contains(plain, text) { | |||
| 162 | + | t.Fatalf("inactive checklist text %q missing: %q", text, plain) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") { | |||
| 166 | + | t.Fatalf("inactive checklist rows were not dimmed: %q", lines) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) { | |||
| 171 | + | line := JoinRightBar(Dark, "main", "right", 8, 8) | |||
| 172 | + | if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) { | |||
| 173 | + | t.Fatalf("separator is not muted: %q", line) | |||
| 174 | + | } | |||
| 175 | + | if strings.ContainsAny(stripANSI(line), "┌┐└┘─") { | |||
| 176 | + | t.Fatalf("joined right bar added an outer rule: %q", line) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 51 | func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | 180 | func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | ||
| 52 | line := JoinRightBar(Dark, "界", "✓ done", 8, 6) | 181 | line := JoinRightBar(Dark, "界", "✓ done", 8, 6) | ||
| 53 | if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want { | 182 | if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want { | ||
| @@ -55,6 +184,31 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |||||
| 55 | } | 184 | } | ||
| 56 | } | 185 | } | ||
| 57 | 186 | ||||
| 187 | + | func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | |||
| 188 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 189 | + | ||||
| 190 | + | var out bytes.Buffer | |||
| 191 | + | r := NewRenderer(&out) | |||
| 192 | + | r.SetTheme(Dark) | |||
| 193 | + | r.Resize(80, 5) | |||
| 194 | + | r.DrawRightBar( | |||
| 195 | + | []string{"chat"}, | |||
| 196 | + | []string{"input"}, | |||
| 197 | + | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |||
| 198 | + | 0, | |||
| 199 | + | 0, | |||
| 200 | + | ) | |||
| 201 | + | ||||
| 202 | + | out.Reset() | |||
| 203 | + | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | |||
| 204 | + | if !strings.Contains(stripANSI(out.String()), "input") { | |||
| 205 | + | t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String()) | |||
| 206 | + | } | |||
| 207 | + | if strings.Contains(out.String(), SeqClearScrollback) { | |||
| 208 | + | t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String()) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 58 | func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | 212 | func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | ||
| 59 | var out bytes.Buffer | 213 | var out bytes.Buffer | ||
| 60 | r := NewRenderer(&out) | 214 | r := NewRenderer(&out) | ||