detached 545e6c1

feat(tui): add right sidebar toggle

commit
545e6c199b80d34828e0763fc310f4b85a4c7652
author
brice · · 2026-08-05 13:32 UTC
parent
4ed7779
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +765 -174
M README.md +1 -0
@@ -888,6 +888,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
888888 | `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. |
889889 | `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. |
890890 | `ctrl+d` | Exit on empty input. |
891+| `ctrl+b` | Toggle the right sidebar; hidden/narrow right-bar widgets fall back above the input within a bounded area and show an ellipsis when truncated. |
891892 | `ctrl+l` | Redraw the screen. |
892893 | `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. |
893894 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). |
@@ -888,6 +888,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
888 | `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. |888 | `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. |
889 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |889 | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. |
890 | `ctrl+d` | Exit on empty input. |890 | `ctrl+d` | Exit on empty input. |
891+| `ctrl+b` | Toggle the right sidebar; hidden/narrow right-bar widgets fall back above the input within a bounded area and show an ellipsis when truncated. |
891 | `ctrl+l` | Redraw the screen. |892 | `ctrl+l` | Redraw the screen. |
892 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |893 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
893 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). |894 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). |
M docs/extensions.md +13 -5
@@ -428,9 +428,12 @@ hint with these interactive-zot values:
428428
429429 The right bar is host-owned: it bounds width and height, orders widgets by
430430 extension and ID, and truncates content that does not fit. On narrow terminals
431-it falls back automatically to `above_input`. Empty or unknown positions retain
432-the historical `above_input` behavior so older extensions remain compatible.
433-Use `open_panel` when a widget needs interaction or navigation.
431+it falls back automatically to `above_input`. In interactive zot, `Ctrl+B`
432+toggles the sidebar; while hidden, right-bar widgets use the same bounded
433+`above_input` fallback and show a truncation marker when the shared chrome
434+budget is exceeded. Empty or unknown positions retain the historical
435+`above_input` behavior so older extensions remain compatible. Use `open_panel`
436+when a widget needs interaction or navigation.
434437
435438 ```json
436439 {"type":"widget","id":"plan","position":"right_bar",
@@ -723,8 +726,13 @@ e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines)
723726 e.ClearWidget("plan")
724727 ```
725728
726-`right_bar` widgets are display-only in the first version. Open an extension
727-panel for editing or keyboard navigation.
729+`right_bar` widgets are display-only in the first version. The host renders
730+one muted separator beside the main pane, leaves one blank row below each
731+widget title, and does not add a second widget frame or top/bottom rules. In
732+interactive zot, `Ctrl+B` toggles the rail; hidden or narrow right-bar widgets
733+use a bounded `above_input` fallback with a truncation marker. Open an
734+extension panel for editing or keyboard navigation; clearing the last
735+right-bar widget removes the rail.
728736
729737 The SDK has four interceptor hooks, all optional:
730738
@@ -428,9 +428,12 @@ hint with these interactive-zot values:
428 428
429 The right bar is host-owned: it bounds width and height, orders widgets by429 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 terminals430 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 retain431+it falls back automatically to `above_input`. In interactive zot, `Ctrl+B`
432-the historical `above_input` behavior so older extensions remain compatible.432+toggles the sidebar; while hidden, right-bar widgets use the same bounded
433-Use `open_panel` when a widget needs interaction or navigation.433+`above_input` fallback and show a truncation marker when the shared chrome
434+budget is exceeded. Empty or unknown positions retain the historical
435+`above_input` behavior so older extensions remain compatible. Use `open_panel`
436+when a widget needs interaction or navigation.
434 437
435 ```json438 ```json
436 {"type":"widget","id":"plan","position":"right_bar",439 {"type":"widget","id":"plan","position":"right_bar",
@@ -723,8 +726,13 @@ e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines)
723 e.ClearWidget("plan")726 e.ClearWidget("plan")
724 ```727 ```
725 728
726-`right_bar` widgets are display-only in the first version. Open an extension729+`right_bar` widgets are display-only in the first version. The host renders
727-panel for editing or keyboard navigation.730+one muted separator beside the main pane, leaves one blank row below each
731+widget title, and does not add a second widget frame or top/bottom rules. In
732+interactive zot, `Ctrl+B` toggles the rail; hidden or narrow right-bar widgets
733+use a bounded `above_input` fallback with a truncation marker. Open an
734+extension panel for editing or keyboard navigation; clearing the last
735+right-bar widget removes the rail.
728 736
729 The SDK has four interceptor hooks, all optional:737 The SDK has four interceptor hooks, all optional:
730 738
M examples/extensions/tasked-phases/README.md +21 -3
@@ -18,6 +18,13 @@ set_current_phase set_task_checked set_phase_checked
1818 clear
1919 ```
2020
21+Successful mutations return one concise line such as `Checked task task-4
22+(p 1/2 | t 3/4)`. `get_status` and `replace_plan` remain detailed when the
23+full state is useful. `set_task_checked` changes one task; `set_phase_checked`
24+intentionally changes every task in the selected phase. Repeated individual
25+checks in one model turn are sequential tool calls, not an automatic bulk
26+update.
27+
2128 ## Build and run
2229
2330 From the zot repository:
@@ -63,7 +70,18 @@ as it is completed, and advance the current phase when work moves forward.
6370 Completed phases and future phases are not repeated in every provider request;
6471 use `get_status` or `/phases` when the full checklist is needed.
6572
66-When a plan is active, the extension publishes its compact checklist through the
67-host's generic `right_bar` widget position. zot keeps it beside the transcript on
68-wide terminals and automatically falls back to the normal above-input widget on
73+When a plan is stored, the extension automatically publishes a compact summary
74+through the host's generic `right_bar` widget position. Both the right-bar title
75+and the status bar use the short form `p done/total | t done/total`; active plans
76+also show their phase and task checklist rows below the title, with one blank
77+row after the title. Phase rows use one leading space and bold phase names;
78+task rows use two leading spaces, and wrapped task text hangs under its task
79+label. The summary and rows are refreshed after every plan mutation and session
80+restore. Checklist markers use `[x]`, `[ ]`, and `[>]`; the host dims rows
81+outside the current phase.
82+Once every phase and task is complete, the persistent status and right-bar widget
83+are cleared. Use `get_status` or `/phases` for the complete checklist and current
84+task details. Spec text is deliberately excluded from persistent chrome. zot
85+keeps the summary beside the transcript on wide
86+terminals and automatically falls back to the normal above-input widget on
6987 narrow terminals. The extension does not implement any terminal layout code.
@@ -18,6 +18,13 @@ set_current_phase set_task_checked set_phase_checked
18 clear18 clear
19 ```19 ```
20 20
21+Successful mutations return one concise line such as `Checked task task-4
22+(p 1/2 | t 3/4)`. `get_status` and `replace_plan` remain detailed when the
23+full state is useful. `set_task_checked` changes one task; `set_phase_checked`
24+intentionally changes every task in the selected phase. Repeated individual
25+checks in one model turn are sequential tool calls, not an automatic bulk
26+update.
27+
21 ## Build and run28 ## Build and run
22 29
23 From the zot repository:30 From the zot repository:
@@ -63,7 +70,18 @@ as it is completed, and advance the current phase when work moves forward.
63 Completed phases and future phases are not repeated in every provider request;70 Completed phases and future phases are not repeated in every provider request;
64 use `get_status` or `/phases` when the full checklist is needed.71 use `get_status` or `/phases` when the full checklist is needed.
65 72
66-When a plan is active, the extension publishes its compact checklist through the73+When a plan is stored, the extension automatically publishes a compact summary
67-host's generic `right_bar` widget position. zot keeps it beside the transcript on74+through the host's generic `right_bar` widget position. Both the right-bar title
68-wide terminals and automatically falls back to the normal above-input widget on75+and the status bar use the short form `p done/total | t done/total`; active plans
76+also show their phase and task checklist rows below the title, with one blank
77+row after the title. Phase rows use one leading space and bold phase names;
78+task rows use two leading spaces, and wrapped task text hangs under its task
79+label. The summary and rows are refreshed after every plan mutation and session
80+restore. Checklist markers use `[x]`, `[ ]`, and `[>]`; the host dims rows
81+outside the current phase.
82+Once every phase and task is complete, the persistent status and right-bar widget
83+are cleared. Use `get_status` or `/phases` for the complete checklist and current
84+task details. Spec text is deliberately excluded from persistent chrome. zot
85+keeps the summary beside the transcript on wide
86+terminals and automatically falls back to the normal above-input widget on
69 narrow terminals. The extension does not implement any terminal layout code.87 narrow terminals. The extension does not implement any terminal layout code.
M examples/extensions/tasked-phases/main.go +117 -25
@@ -4,7 +4,6 @@ import (
44 "encoding/json"
55 "fmt"
66 "os"
7- "strings"
87 "sync"
98
109 "github.com/patriceckhart/zot/packages/agent/ext"
@@ -88,10 +87,14 @@ func main() {
8887 }
8988 if err := a.store.load(dataDir, host.CWD); err != nil {
9089 e.Logf("load project state: %v", err)
90+ a.publishUnavailableChrome()
9191 return
9292 }
9393 if state, err := a.store.snapshot(); err == nil {
9494 a.publishChrome(state)
95+ } else {
96+ e.Logf("snapshot project state: %v", err)
97+ a.publishUnavailableChrome()
9598 }
9699 })
97100 restoreSession := func(event ext.Event) {
@@ -103,6 +106,9 @@ func main() {
103106 if state, err := a.store.snapshot(); err == nil {
104107 a.publishChrome(state)
105108 a.renderPanelIfOpen(state)
109+ } else {
110+ e.Logf("restore session snapshot: %v", err)
111+ a.publishUnavailableChrome()
106112 }
107113 }
108114 e.On("session_opened", restoreSession)
@@ -132,6 +138,7 @@ func main() {
132138 func (a *app) handleCommand(_ string) ext.Response {
133139 state, err := a.store.snapshot()
134140 if err != nil {
141+ a.publishUnavailableChrome()
135142 return ext.Errorf("load tasked phases: %v", err)
136143 }
137144 a.publishChrome(state)
@@ -177,6 +184,7 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
177184 if params.Action == actionGetStatus {
178185 state, err := a.store.snapshot()
179186 if err != nil {
187+ a.publishUnavailableChrome()
180188 return toolResult(params.Action, PlanState{}, "", err)
181189 }
182190 a.publishChrome(state)
@@ -188,11 +196,13 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
188196 return applyAction(current, params)
189197 })
190198 // 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.
194202 if err == nil || state.Version != 0 {
195203 a.publishChrome(state)
204+ } else {
205+ a.publishUnavailableChrome()
196206 }
197207 if err == nil {
198208 a.renderPanelIfOpen(state)
@@ -201,8 +211,7 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
201211 }
202212
203213 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)
206215 a.panelMu.Lock()
207216 open := a.panelOpen
208217 a.panelMu.Unlock()
@@ -212,30 +221,109 @@ func (a *app) publishUnavailableChrome() {
212221 }
213222
214223 type chromeContent struct {
215- status string
216- lines []string
224+ summary string
225+ lines []string
217226 }
218227
228+const (
229+ chromePhaseMaxChars = 32
230+ chromeTaskMaxChars = 64
231+)
232+
219233 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) {
222238 return chromeContent{}, false
223239 }
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- }
233240 return chromeContent{
234- status: status,
235- lines: strings.Split(buildCompactSummary(state), "\n"),
241+ summary: buildChromeSummary(state),
242+ lines: buildChecklistLines(state),
236243 }, true
237244 }
238245
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+
239327 type chromeHost interface {
240328 SetStatus(key, text string)
241329 ClearStatus(key string)
@@ -243,15 +331,19 @@ type chromeHost interface {
243331 ClearWidget(id string)
244332 }
245333
334+func clearChrome(host chromeHost) {
335+ host.ClearStatus("progress")
336+ host.ClearWidget("plan")
337+}
338+
246339 func applyChrome(host chromeHost, state PlanState) {
247340 chrome, ok := buildChrome(state)
248341 if !ok {
249- host.ClearStatus("progress")
250- host.ClearWidget("plan")
342+ clearChrome(host)
251343 return
252344 }
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)
255347 }
256348
257349 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 return91 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 not198 // A zero state means transact could not obtain a loaded snapshot. Do not
191- // replace persistent chrome with an invented empty plan. For action and199+ // replace persistent chrome with an invented empty plan; clear it instead.
192- // persistence errors, transact returns the unchanged usable state, which200+ // 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.panelOpen216 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 string224+ summary string
216- lines []string225+ 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{}, false238 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 }, true243 }, 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 return343 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) {
1212 if visible {
1313 t.Fatal("empty plan should not publish persistent chrome")
1414 }
15- if chrome.status != "" || len(chrome.lines) != 0 {
15+ if chrome.summary != "" || len(chrome.lines) != 0 {
1616 t.Fatalf("empty chrome = %#v, want zero content", chrome)
1717 }
1818 }
1919
20-func TestBuildChromeShowsStoredPlan(t *testing.T) {
20+func TestBuildChromeUsesCompactSummaryWithoutSpecText(t *testing.T) {
2121 state := PlanState{
2222 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",
2940 }
3041
3142 chrome, visible := buildChrome(state)
3243 if !visible {
3344 t.Fatal("stored plan should publish persistent chrome")
3445 }
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)
3751 }
3852 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+ } {
4059 if !strings.Contains(joined, want) {
41- t.Fatalf("chrome lines missing %q: %s", want, joined)
60+ t.Fatalf("checklist missing %q: %s", want, joined)
4261 }
4362 }
4463 }
4564
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+
46101 type chromeRecorder struct {
47102 statusSet int
48103 statusClear int
104+ statusTexts []string
49105 widgetSet int
50106 widgetClear int
51107 widgetPositions []string
108+ widgetTitles []string
109+ widgetLines [][]string
52110 }
53111
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) {
57118 r.widgetSet++
58119 r.widgetPositions = append(r.widgetPositions, position)
120+ r.widgetTitles = append(r.widgetTitles, title)
121+ r.widgetLines = append(r.widgetLines, append([]string(nil), lines...))
59122 }
60123 func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ }
61124
62-func TestApplyChromeUsesRightBar(t *testing.T) {
125+func TestApplyChromePublishesCompactStatusAndTitle(t *testing.T) {
63126 recorder := &chromeRecorder{}
64- applyChrome(recorder, PlanState{
127+ state := PlanState{
65128 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)
73144
74145 if recorder.widgetSet != 1 || len(recorder.widgetPositions) != 1 || recorder.widgetPositions[0] != ext.WidgetPositionRightBar {
75146 t.Fatalf("widget placement = %v, want %q", recorder.widgetPositions, ext.WidgetPositionRightBar)
76147 }
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+ }
77161 }
78162
79163 func TestApplyChromeClearsEmptyPlan(t *testing.T) {
@@ -87,3 +171,42 @@ func TestApplyChromeClearsEmptyPlan(t *testing.T) {
87171 t.Fatalf("empty plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet)
88172 }
89173 }
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 int102 statusSet int
48 statusClear int103 statusClear int
104+ statusTexts []string
49 widgetSet int105 widgetSet int
50 widgetClear int106 widgetClear int
51 widgetPositions []string107 widgetPositions []string
108+ widgetTitles []string
109+ widgetLines [][]string
52 }110 }
53 111
54-func (r *chromeRecorder) SetStatus(string, string) { r.statusSet++ }112+func (r *chromeRecorder) SetStatus(_, text string) {
55-func (r *chromeRecorder) ClearStatus(string) { r.statusClear++ }113+ r.statusSet++
56-func (r *chromeRecorder) SetWidget(_, position, _ string, _ []string) {114+ r.statusTexts = append(r.statusTexts, text)
115+}
116+func (r *chromeRecorder) ClearStatus(string) { r.statusClear++ }
117+func (r *chromeRecorder) SetWidget(_, position, title string, lines []string) {
57 r.widgetSet++118 r.widgetSet++
58 r.widgetPositions = append(r.widgetPositions, position)119 r.widgetPositions = append(r.widgetPositions, position)
120+ r.widgetTitles = append(r.widgetTitles, title)
121+ r.widgetLines = append(r.widgetLines, append([]string(nil), lines...))
59 }122 }
60 func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ }123 func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ }
61 124
62-func TestApplyChromeUsesRightBar(t *testing.T) {125+func TestApplyChromePublishesCompactStatusAndTitle(t *testing.T) {
63 recorder := &chromeRecorder{}126 recorder := &chromeRecorder{}
64- applyChrome(recorder, PlanState{127+ state := PlanState{
65 Version: 1,128 Version: 1,
66- Spec: "Keep the plan visible",129+ Spec: "must not be shown",
67- Phases: []Phase{{130+ Phases: []Phase{
68- ID: "phase-1",131+ {
69- Title: "Implement",132+ ID: "phase-1",
70- Tasks: []PhaseTask{{ID: "task-1", Text: "Ship"}},133+ Title: "Finished",
71- }},134+ Tasks: []PhaseTask{{ID: "task-1", Text: "Ship", Checked: true}},
72- })135+ },
136+ {
137+ ID: "phase-2",
138+ Title: "Pending",
139+ Tasks: []PhaseTask{{ID: "task-2", Text: "Review"}},
140+ },
141+ },
142+ }
143+ applyChrome(recorder, state)
73 144
74 if recorder.widgetSet != 1 || len(recorder.widgetPositions) != 1 || recorder.widgetPositions[0] != ext.WidgetPositionRightBar {145 if recorder.widgetSet != 1 || len(recorder.widgetPositions) != 1 || recorder.widgetPositions[0] != ext.WidgetPositionRightBar {
75 t.Fatalf("widget placement = %v, want %q", recorder.widgetPositions, ext.WidgetPositionRightBar)146 t.Fatalf("widget placement = %v, want %q", recorder.widgetPositions, ext.WidgetPositionRightBar)
76 }147 }
148+ want := buildChromeSummary(state)
149+ if len(recorder.statusTexts) != 1 || recorder.statusTexts[0] != want {
150+ t.Fatalf("status updates = %v, want %q", recorder.statusTexts, want)
151+ }
152+ if len(recorder.widgetTitles) != 1 || recorder.widgetTitles[0] != want {
153+ t.Fatalf("widget titles = %v, want %q", recorder.widgetTitles, want)
154+ }
155+ if len(recorder.widgetLines) != 1 || len(recorder.widgetLines[0]) == 0 {
156+ t.Fatalf("widget lines = %v, want checklist rows", recorder.widgetLines)
157+ }
158+ if !strings.Contains(strings.Join(recorder.widgetLines[0], "\n"), "[>] Review") {
159+ t.Fatalf("active task is missing from widget lines: %v", recorder.widgetLines[0])
160+ }
77 }161 }
78 162
79 func TestApplyChromeClearsEmptyPlan(t *testing.T) {163 func TestApplyChromeClearsEmptyPlan(t *testing.T) {
@@ -87,3 +171,42 @@ func TestApplyChromeClearsEmptyPlan(t *testing.T) {
87 t.Fatalf("empty plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet)171 t.Fatalf("empty plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet)
88 }172 }
89 }173 }
174+
175+func TestApplyChromeClearsCompletedPlan(t *testing.T) {
176+ recorder := &chromeRecorder{}
177+ state := PlanState{
178+ Version: 1,
179+ Phases: []Phase{{
180+ ID: "phase-1",
181+ Title: "Ship",
182+ Tasks: []PhaseTask{{ID: "task-1", Text: "Done", Checked: true}},
183+ }},
184+ }
185+ applyChrome(recorder, state)
186+
187+ if recorder.statusClear != 1 || recorder.widgetClear != 1 {
188+ t.Fatalf("complete plan clear operations = status %d, widget %d; want one each", recorder.statusClear, recorder.widgetClear)
189+ }
190+ if recorder.statusSet != 0 || recorder.widgetSet != 0 {
191+ t.Fatalf("complete plan published updates = status %d, widget %d; want none", recorder.statusSet, recorder.widgetSet)
192+ }
193+}
194+
195+func TestApplyChromeShowsEmptySummaryWithoutSpecText(t *testing.T) {
196+ recorder := &chromeRecorder{}
197+ state := PlanState{Version: 1, Spec: "spec only"}
198+ applyChrome(recorder, state)
199+
200+ if recorder.statusClear != 0 || recorder.widgetClear != 0 || recorder.statusSet != 1 || recorder.widgetSet != 1 {
201+ t.Fatalf("spec-only chrome lifecycle = %#v, want a compact update", recorder)
202+ }
203+ if got, want := recorder.statusTexts[0], "p 0/0 | t 0/0"; got != want {
204+ t.Fatalf("spec-only status = %q, want %q", got, want)
205+ }
206+ if got, want := recorder.widgetTitles[0], "p 0/0 | t 0/0"; got != want {
207+ t.Fatalf("spec-only widget title = %q, want %q", got, want)
208+ }
209+ if len(recorder.widgetLines[0]) != 1 || recorder.widgetLines[0][0] != "No phases yet" {
210+ t.Fatalf("spec-only widget lines = %v, want empty-plan hint", recorder.widgetLines[0])
211+ }
212+}
M examples/extensions/tasked-phases/skills/tasked-phases/SKILL.md +14 -0
@@ -18,6 +18,20 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren
1818 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
1919 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.
2020
21+The right-bar summary and status bar are the persistent UI: both refresh after
22+each successful mutation and session restore and use `p done/total | t done/total`.
23+Active plans also publish their phase and task rows below the compact title; the
24+host dims rows outside the current phase. Use `[x]`, `[ ]`, and `[>]` markers.
25+Use `get_status` or `/phases` when the complete checklist is needed. Persistent
26+chrome is cleared automatically once every phase and task is complete. Keep
27+phase titles to 32 characters or fewer and task text to 64 characters or fewer
28+in plans and detailed views. Routine successful mutations return one concise
29+progress line; use `get_status` or `replace_plan` when detailed output is needed.
30+`set_task_checked` changes one task. `set_phase_checked` intentionally checks
31+or unchecks every task in one phase, so use it only for an intentional phase-wide
32+update. Multiple individual checks in one turn are sequential calls, not an
33+automatic bulk update.
34+
2135 ## Planning rules
2236
2337 - Prefer 3–7 phases unless the task is tiny.
@@ -18,6 +18,20 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren
18 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.18 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.19 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.
20 20
21+The right-bar summary and status bar are the persistent UI: both refresh after
22+each successful mutation and session restore and use `p done/total | t done/total`.
23+Active plans also publish their phase and task rows below the compact title; the
24+host dims rows outside the current phase. Use `[x]`, `[ ]`, and `[>]` markers.
25+Use `get_status` or `/phases` when the complete checklist is needed. Persistent
26+chrome is cleared automatically once every phase and task is complete. Keep
27+phase titles to 32 characters or fewer and task text to 64 characters or fewer
28+in plans and detailed views. Routine successful mutations return one concise
29+progress line; use `get_status` or `replace_plan` when detailed output is needed.
30+`set_task_checked` changes one task. `set_phase_checked` intentionally checks
31+or unchecks every task in one phase, so use it only for an intentional phase-wide
32+update. Multiple individual checks in one turn are sequential calls, not an
33+automatic bulk update.
34+
21 ## Planning rules35 ## Planning rules
22 36
23 - Prefer 3–7 phases unless the task is tiny.37 - Prefer 3–7 phases unless the task is tiny.
M examples/extensions/tasked-phases/state.go +5 -49
@@ -774,7 +774,6 @@ func checkedWord(checked bool) string {
774774 }
775775
776776 const (
777- compactVisibleIncompleteTasks = 4
778777 turnContextVisibleIncompleteTasks = 3
779778 maxTurnContextChars = 2048
780779 )
@@ -789,13 +788,6 @@ func getIncompleteTasks(phase Phase) []PhaseTask {
789788 return incomplete
790789 }
791790
792-func formatRemainingTaskCount(count int) string {
793- if count == 1 {
794- return "1 remaining"
795- }
796- return fmt.Sprintf("%d remaining", count)
797-}
798-
799791 func formatPhaseTitle(phase Phase) string { return truncatePlain(displaySingleLine(phase.Title), 120) }
800792 func formatTaskText(task PhaseTask) string { return truncatePlain(displaySingleLine(task.Text), 180) }
801793
@@ -865,41 +857,6 @@ func buildSummary(state PlanState) string {
865857 return strings.Join(lines, "\n")
866858 }
867859
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-
903860 // buildTurnContext returns the small amount of plan state the model needs at
904861 // the start of a turn. It deliberately excludes the spec, completed phases,
905862 // and future phases; get_status and /phases remain the full-state views.
@@ -943,14 +900,13 @@ func shouldReturnFullSummary(action string, err error) bool {
943900 }
944901
945902 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- }
950903 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)
952908 }
953- return headline + "\n\n" + summary
909+ return fmt.Sprintf("%s (%s)", headline, buildChromeSummary(state))
954910 }
955911
956912 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 = 3777 turnContextVisibleIncompleteTasks = 3
779 maxTurnContextChars = 2048778 maxTurnContextChars = 2048
780 )779 )
@@ -789,13 +788,6 @@ func getIncompleteTasks(phase Phase) []PhaseTask {
789 return incomplete788 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 at860 // 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" + summary904+ 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" + summary909+ 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) {
184184 func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) {
185185 state := sampleState()
186186 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)
192189 }
193190 full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil)
194191 if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") {
@@ -286,7 +283,7 @@ func TestDisplayRenderingSanitizesUntrustedTextWithoutMutatingState(t *testing.T
286283 original := state
287284
288285 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"),
290287 } {
291288 if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') {
292289 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 := state283 original := state
287 284
288 for _, rendered := range []string{285 for _, rendered := range []string{
289- buildSummary(state), buildCompactSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"),286+ buildSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"),
290 } {287 } {
291 if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') {288 if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') {
292 t.Fatalf("rendered text contains terminal/control injection: %q", rendered)289 t.Fatalf("rendered text contains terminal/control injection: %q", rendered)
M examples/skills/tasked-phases/SKILL.md +14 -0
@@ -18,6 +18,20 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren
1818 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
1919 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.
2020
21+The right-bar summary and status bar are the persistent UI: both refresh after
22+each successful mutation and session restore and use `p done/total | t done/total`.
23+Active plans also publish their phase and task rows below the compact title; the
24+host dims rows outside the current phase. Use `[x]`, `[ ]`, and `[>]` markers.
25+Use `get_status` or `/phases` when the complete checklist is needed. Persistent
26+chrome is cleared automatically once every phase and task is complete. Keep
27+phase titles to 32 characters or fewer and task text to 64 characters or fewer
28+in plans and detailed views. Routine successful mutations return one concise
29+progress line; use `get_status` or `replace_plan` when detailed output is needed.
30+`set_task_checked` changes one task. `set_phase_checked` intentionally checks
31+or unchecks every task in one phase, so use it only for an intentional phase-wide
32+update. Multiple individual checks in one turn are sequential calls, not an
33+automatic bulk update.
34+
2135 ## Planning rules
2236
2337 - Prefer 3–7 phases unless the task is tiny.
@@ -18,6 +18,20 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren
18 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.18 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.19 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.
20 20
21+The right-bar summary and status bar are the persistent UI: both refresh after
22+each successful mutation and session restore and use `p done/total | t done/total`.
23+Active plans also publish their phase and task rows below the compact title; the
24+host dims rows outside the current phase. Use `[x]`, `[ ]`, and `[>]` markers.
25+Use `get_status` or `/phases` when the complete checklist is needed. Persistent
26+chrome is cleared automatically once every phase and task is complete. Keep
27+phase titles to 32 characters or fewer and task text to 64 characters or fewer
28+in plans and detailed views. Routine successful mutations return one concise
29+progress line; use `get_status` or `replace_plan` when detailed output is needed.
30+`set_task_checked` changes one task. `set_phase_checked` intentionally checks
31+or unchecks every task in one phase, so use it only for an intentional phase-wide
32+update. Multiple individual checks in one turn are sequential calls, not an
33+automatic bulk update.
34+
21 ## Planning rules35 ## Planning rules
22 36
23 - Prefer 3–7 phases unless the task is tiny.37 - Prefer 3–7 phases unless the task is tiny.
M packages/agent/modes/ext_notes_test.go +52 -1
@@ -1,6 +1,7 @@
11 package modes
22
33 import (
4+ "context"
45 "fmt"
56 "strings"
67 "testing"
@@ -82,6 +83,28 @@ func TestPersistentExtensionStatusRowsAreBounded(t *testing.T) {
8283 }
8384 }
8485
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+
85108 func TestPersistentExtensionStatusAndWidget(t *testing.T) {
86109 i := newNotesTestInteractive()
87110 i.SetStatus("tasked-phases", "progress", "success", "2/4 tasks checked")
@@ -170,7 +193,7 @@ func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) {
170193 i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"})
171194 i.redraw()
172195
173- out := term.String()
196+ out := stripANSIBytes(term.String())
174197 if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") {
175198 t.Fatalf("wide redraw omitted right-bar content: %q", out)
176199 }
@@ -178,3 +201,31 @@ func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) {
178201 t.Fatal("wide redraw omitted the main-pane divider")
179202 }
180203 }
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 modes1 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{
2020 {"ctrl+u / ctrl+k", "delete to start / end of line"},
2121 {"ctrl+a / ctrl+e", "jump to start / end of line"},
2222 {"alt+← / alt+→", "jump one word back / forward"},
23+ {"ctrl+b", "toggle the right sidebar"},
2324 {"ctrl+l", "redraw the screen"},
2425 {"ctrl+o", "expand / collapse long tool results"},
2526 {"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 (
99
1010 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {
1111 without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n")
12+ if !strings.Contains(without, "ctrl+b") {
13+ t.Fatalf("help omitted the right-sidebar shortcut: %q", without)
14+ }
1215 if strings.Contains(without, "/llama") {
1316 t.Fatalf("help exposed /llama without login: %q", without)
1417 }
@@ -9,6 +9,9 @@ import (
9 9
10 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {10 func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) {
11 without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n")11 without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n")
12+ if !strings.Contains(without, "ctrl+b") {
13+ t.Fatalf("help omitted the right-sidebar shortcut: %q", without)
14+ }
12 if strings.Contains(without, "/llama") {15 if strings.Contains(without, "/llama") {
13 t.Fatalf("help exposed /llama without login: %q", without)16 t.Fatalf("help exposed /llama without login: %q", without)
14 }17 }
M packages/agent/modes/interactive.go +26 -2
@@ -514,6 +514,7 @@ type Interactive struct {
514514 reloadStatusSeq uint64
515515 extStatuses map[string]map[string]extensionStatus
516516 extWidgets map[string]map[string]extensionWidget
517+ rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
517518 liveBlock []string // live streaming/tool progress rendered outside scrollback
518519 helpBlock []string // rendered above the chat when /help was typed
519520 cumUsage provider.Usage
@@ -1235,6 +1236,10 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
12351236 const maxExtensionWidgetRows = 12
12361237 const maxExtensionStatusRows = 6
12371238
1239+// maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back
1240+// above the input, leaving one row for the truncation marker.
1241+const maxNarrowExtensionChromeRows = 6
1242+
12381243 func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
12391244 var out []tui.RightBarWidget
12401245 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
@@ -1258,6 +1263,17 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
12581263 return i.extensionChromeLinesAtLocked(cols, false)
12591264 }
12601265
1266+func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
1267+ lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
1268+ if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
1269+ return lines
1270+ }
1271+ limit := maxNarrowExtensionChromeRows
1272+ trimmed := append([]string(nil), lines[:limit-1]...)
1273+ trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1274+ return trimmed
1275+}
1276+
12611277 func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
12621278 var out []string
12631279 bodyWidth := cols - 2
@@ -1591,13 +1607,14 @@ func (i *Interactive) redraw() {
15911607 rightBarWidgets := i.rightBarWidgetsLocked()
15921608 rightBarActive := false
15931609 var rightBarWidth int
1594- if len(rightBarWidgets) > 0 {
1610+ if !i.rightBarHidden && len(rightBarWidgets) > 0 {
15951611 if width, rail, ok := tui.RightBarColumns(cols); ok {
15961612 mainCols = width
15971613 rightBarWidth = rail
15981614 rightBarActive = true
15991615 }
16001616 }
1617+ rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
16011618 chat := i.cachedChatLocked(mainCols)
16021619
16031620 // Dialogs (login or model picker) render between chat and the editor.
@@ -1834,7 +1851,7 @@ func (i *Interactive) redraw() {
18341851 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
18351852 }
18361853
1837- extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive)
1854+ extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
18381855
18391856 // Bottom-sticky sections (always visible, never scroll). Each
18401857 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -2921,6 +2938,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
29212938 if i.ed.IsEmpty() && !i.busy {
29222939 return true
29232940 }
2941+ case tui.KeyCtrlB:
2942+ i.mu.Lock()
2943+ i.rightBarHidden = !i.rightBarHidden
2944+ i.mu.Unlock()
2945+ i.rend.Invalidate()
2946+ i.invalidate()
2947+ return false
29242948 case tui.KeyCtrlL:
29252949 i.rend.Clear()
29262950 i.invalidate()
@@ -514,6 +514,7 @@ type Interactive struct {
514 reloadStatusSeq uint64514 reloadStatusSeq uint64
515 extStatuses map[string]map[string]extensionStatus515 extStatuses map[string]map[string]extensionStatus
516 extWidgets map[string]map[string]extensionWidget516 extWidgets map[string]map[string]extensionWidget
517+ rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
517 liveBlock []string // live streaming/tool progress rendered outside scrollback518 liveBlock []string // live streaming/tool progress rendered outside scrollback
518 helpBlock []string // rendered above the chat when /help was typed519 helpBlock []string // rendered above the chat when /help was typed
519 cumUsage provider.Usage520 cumUsage provider.Usage
@@ -1235,6 +1236,10 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
1235 const maxExtensionWidgetRows = 121236 const maxExtensionWidgetRows = 12
1236 const maxExtensionStatusRows = 61237 const maxExtensionStatusRows = 6
1237 1238
1239+// maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back
1240+// above the input, leaving one row for the truncation marker.
1241+const maxNarrowExtensionChromeRows = 6
1242+
1238 func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {1243 func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1239 var out []tui.RightBarWidget1244 var out []tui.RightBarWidget
1240 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {1245 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
@@ -1258,6 +1263,17 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1258 return i.extensionChromeLinesAtLocked(cols, false)1263 return i.extensionChromeLinesAtLocked(cols, false)
1259 }1264 }
1260 1265
1266+func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
1267+ lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
1268+ if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
1269+ return lines
1270+ }
1271+ limit := maxNarrowExtensionChromeRows
1272+ trimmed := append([]string(nil), lines[:limit-1]...)
1273+ trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1274+ return trimmed
1275+}
1276+
1261 func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {1277 func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
1262 var out []string1278 var out []string
1263 bodyWidth := cols - 21279 bodyWidth := cols - 2
@@ -1591,13 +1607,14 @@ func (i *Interactive) redraw() {
1591 rightBarWidgets := i.rightBarWidgetsLocked()1607 rightBarWidgets := i.rightBarWidgetsLocked()
1592 rightBarActive := false1608 rightBarActive := false
1593 var rightBarWidth int1609 var rightBarWidth int
1594- if len(rightBarWidgets) > 0 {1610+ if !i.rightBarHidden && len(rightBarWidgets) > 0 {
1595 if width, rail, ok := tui.RightBarColumns(cols); ok {1611 if width, rail, ok := tui.RightBarColumns(cols); ok {
1596 mainCols = width1612 mainCols = width
1597 rightBarWidth = rail1613 rightBarWidth = rail
1598 rightBarActive = true1614 rightBarActive = true
1599 }1615 }
1600 }1616 }
1617+ rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1601 chat := i.cachedChatLocked(mainCols)1618 chat := i.cachedChatLocked(mainCols)
1602 1619
1603 // Dialogs (login or model picker) render between chat and the editor.1620 // Dialogs (login or model picker) render between chat and the editor.
@@ -1834,7 +1851,7 @@ func (i *Interactive) redraw() {
1834 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))1851 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1835 }1852 }
1836 1853
1837- extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive)1854+ extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
1838 1855
1839 // Bottom-sticky sections (always visible, never scroll). Each1856 // Bottom-sticky sections (always visible, never scroll). Each
1840 // non-empty subsection (dialog, suggest popup, sliding-in queue)1857 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -2921,6 +2938,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2921 if i.ed.IsEmpty() && !i.busy {2938 if i.ed.IsEmpty() && !i.busy {
2922 return true2939 return true
2923 }2940 }
2941+ case tui.KeyCtrlB:
2942+ i.mu.Lock()
2943+ i.rightBarHidden = !i.rightBarHidden
2944+ i.mu.Unlock()
2945+ i.rend.Invalidate()
2946+ i.invalidate()
2947+ return false
2924 case tui.KeyCtrlL:2948 case tui.KeyCtrlL:
2925 i.rend.Clear()2949 i.rend.Clear()
2926 i.invalidate()2950 i.invalidate()
M packages/tui/input.go +5 -0
@@ -41,6 +41,7 @@ const (
4141 KeyCtrlU
4242 KeyCtrlK
4343 KeyCtrlA
44+ KeyCtrlB
4445 KeyCtrlE
4546 KeyCtrlW
4647 KeyCtrlO
@@ -179,6 +180,8 @@ func (r *Reader) Read() (Key, error) {
179180 return Key{Kind: KeyCtrlK}, nil
180181 case b == 0x01:
181182 return Key{Kind: KeyCtrlA}, nil
183+ case b == 0x02:
184+ return Key{Kind: KeyCtrlB}, nil
182185 case b == 0x05:
183186 return Key{Kind: KeyCtrlE}, nil
184187 case b == 0x17:
@@ -488,6 +491,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
488491 return Key{Kind: KeyCtrlK, Shift: shift, Alt: alt, Ctrl: true}, true
489492 case 'a', 'A':
490493 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
491496 case 'e', 'E':
492497 return Key{Kind: KeyCtrlE, Shift: shift, Alt: alt, Ctrl: true}, true
493498 case 'w', 'W':
@@ -41,6 +41,7 @@ const (
41 KeyCtrlU41 KeyCtrlU
42 KeyCtrlK42 KeyCtrlK
43 KeyCtrlA43 KeyCtrlA
44+ KeyCtrlB
44 KeyCtrlE45 KeyCtrlE
45 KeyCtrlW46 KeyCtrlW
46 KeyCtrlO47 KeyCtrlO
@@ -179,6 +180,8 @@ func (r *Reader) Read() (Key, error) {
179 return Key{Kind: KeyCtrlK}, nil180 return Key{Kind: KeyCtrlK}, nil
180 case b == 0x01:181 case b == 0x01:
181 return Key{Kind: KeyCtrlA}, nil182 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}, nil186 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}, true491 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}, true493 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}, true497 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) {
6262 }
6363 }
6464
65+func TestReaderParsesCtrlB(t *testing.T) {
66+ for _, input := range []string{"\x02", "\x1b[98;5u", "\x1b[27;5;98~"} {
67+ k := readKey(t, input)
68+ if k.Kind != KeyCtrlB {
69+ t.Fatalf("Read(%q) kind=%v ctrl=%v, want ctrl+b", input, k.Kind, k.Ctrl)
70+ }
71+ if input != "\x02" && !k.Ctrl {
72+ t.Fatalf("Read(%q) ctrl=%v, want enhanced ctrl+b metadata", input, k.Ctrl)
73+ }
74+ }
75+}
76+
6577 func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) {
6678 k := readKey(t, "\x1b[118;5u")
6779 if k.Kind != KeyPasteClipboard || !k.Ctrl {
@@ -62,6 +62,18 @@ func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
62 }62 }
63 }63 }
64 64
65+func TestReaderParsesCtrlB(t *testing.T) {
66+ for _, input := range []string{"\x02", "\x1b[98;5u", "\x1b[27;5;98~"} {
67+ k := readKey(t, input)
68+ if k.Kind != KeyCtrlB {
69+ t.Fatalf("Read(%q) kind=%v ctrl=%v, want ctrl+b", input, k.Kind, k.Ctrl)
70+ }
71+ if input != "\x02" && !k.Ctrl {
72+ t.Fatalf("Read(%q) ctrl=%v, want enhanced ctrl+b metadata", input, k.Ctrl)
73+ }
74+ }
75+}
76+
65 func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) {77 func TestReaderParsesCSIUCtrlVAsClipboardPaste(t *testing.T) {
66 k := readKey(t, "\x1b[118;5u")78 k := readKey(t, "\x1b[118;5u")
67 if k.Kind != KeyPasteClipboard || !k.Ctrl {79 if k.Kind != KeyPasteClipboard || !k.Ctrl {
M packages/tui/rightbar.go +217 -56
@@ -57,31 +57,15 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
5757 return available - rightBarWidth, rightBarWidth, true
5858 }
5959
60-// RenderRightBar renders a bounded, full-height side rail. Widgets are sorted
61-// by extension name and ID so output stays deterministic even when extension
62-// frames arrive concurrently. Content that does not fit is replaced by a
63-// compact marker rather than overflowing the terminal or growing the frame.
60+// RenderRightBar renders a bounded, full-height side rail without adding a
61+// second frame around the host-owned separator. Widgets are sorted by
62+// extension name and ID so output stays deterministic even when extension
63+// frames arrive concurrently. Long content wraps to the rail width, and
64+// content that does not fit is replaced by a compact marker.
6465 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
6566 if width <= 0 || height <= 0 {
6667 return nil
6768 }
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- }
8569
8670 ordered := append([]RightBarWidget(nil), widgets...)
8771 sort.SliceStable(ordered, func(a, b int) bool {
@@ -91,40 +75,91 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
9175 return ordered[a].ID < ordered[b].ID
9276 })
9377
94- innerWidth := width - 2
95- contentRows := height - 2
96- content := make([]string, 0, contentRows)
78+ content := make([]string, 0, height)
9779 truncated := false
98- appendContent := func(text string, color int) bool {
99- if len(content) >= contentRows {
100- truncated = true
101- return false
80+ padLine := func(text string) string {
81+ text = truncateToWidth(text, width)
82+ if visible := visibleWidth(text); visible < width {
83+ text += strings.Repeat(" ", width-visible)
84+ }
85+ return text
86+ }
87+ paintLine := func(text string, color int, dim, phase bool) string {
88+ text = padLine(text)
89+ if color < 0 {
90+ return text
91+ }
92+ paint := func(value string, valueColor int) string {
93+ if dim {
94+ value = Dim(value)
95+ }
96+ return th.FG256(valueColor, value)
97+ }
98+ if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
99+ if phase {
100+ if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
101+ return paint(text[:markerStart], color) +
102+ paint(text[markerStart:markerEnd], th.Muted) +
103+ paint(text[markerEnd:nameStart], color) +
104+ paint(Bold(text[nameStart:nameEnd]), color) +
105+ paint(text[nameEnd:], color)
106+ }
107+ }
108+ return paint(text[:markerStart], color) +
109+ paint(text[markerStart:markerEnd], th.Muted) +
110+ paint(text[markerEnd:], color)
102111 }
103- text = strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ↩ ")
104- text = truncateToWidth(text, innerWidth)
105- if visible := visibleWidth(text); visible < innerWidth {
106- text += strings.Repeat(" ", innerWidth-visible)
112+ if phase {
113+ return paint(Bold(text), color)
107114 }
108- if color >= 0 {
109- text = th.FG256(color, text)
115+ return paint(text, color)
116+ }
117+ appendContent := func(text string, color int, dim, phase bool) bool {
118+ text = strings.ReplaceAll(text, "\r", "")
119+ for _, physicalLine := range strings.Split(text, "\n") {
120+ wrapped := rightBarWrapLine(physicalLine, width)
121+ if len(wrapped) == 0 {
122+ wrapped = []string{""}
123+ }
124+ for _, line := range wrapped {
125+ if len(content) >= height {
126+ truncated = true
127+ return false
128+ }
129+ content = append(content, paintLine(line, color, dim, phase))
130+ }
110131 }
111- content = append(content, text)
112132 return true
113133 }
114134
115135 for idx, widget := range ordered {
116- if idx > 0 && !appendContent("", -1) {
136+ if idx > 0 && !appendContent("", -1, false, false) {
117137 break
118138 }
119139 label := "[" + widget.Extension + "]"
120140 if widget.Title != "" {
121141 label += " " + widget.Title
122142 }
123- if !appendContent(label, th.Accent) {
143+ if !appendContent(label, th.Accent, false, false) {
144+ break
145+ }
146+ if !appendContent("", -1, false, false) {
124147 break
125148 }
149+ activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
150+ if activePhaseIndent >= 0 {
151+ activePhaseIndent++ // host-owned line prefix
152+ }
153+ phaseIndent := rightBarChecklistIndent(widget.Lines)
154+ if phaseIndent >= 0 {
155+ phaseIndent++ // host-owned line prefix
156+ }
157+ activePhase := false
126158 for _, line := range widget.Lines {
127- if !appendContent(" "+line, th.FG) {
159+ line = " " + line
160+ dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
161+ phase := rightBarChecklistPhaseLine(line, phaseIndent)
162+ if !appendContent(line, th.FG, dim, phase) {
128163 break
129164 }
130165 }
@@ -133,30 +168,156 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
133168 }
134169 }
135170 if truncated {
136- marker := "... right bar ..."
137- marker = truncateToWidth(marker, innerWidth)
138- if len(content) == contentRows {
139- content[len(content)-1] = ""
171+ marker := paintLine("... right bar ...", th.Muted, false, false)
172+ if len(content) == height {
173+ content[len(content)-1] = marker
174+ } else {
175+ content = append(content, marker)
176+ }
177+ }
178+ for len(content) < height {
179+ content = append(content, strings.Repeat(" ", width))
180+ }
181+ return content
182+}
183+
184+func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) {
185+ start = 0
186+ for start < len(text) && text[start] == ' ' {
187+ start++
188+ }
189+ if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' {
190+ return 0, 0, 0, false
191+ }
192+ switch text[start+1] {
193+ case ' ', 'x', 'X', '>':
194+ return start, start + 3, text[start+1], true
195+ default:
196+ return 0, 0, 0, false
197+ }
198+}
199+
200+func rightBarWrapLine(text string, width int) []string {
201+ _, end, _, ok := rightBarChecklistMarker(text)
202+ if !ok {
203+ return WrapANSILine(text, width)
204+ }
205+
206+ bodyStart := end
207+ if bodyStart < len(text) && text[bodyStart] == ' ' {
208+ bodyStart++
209+ }
210+ prefix := text[:bodyStart]
211+ prefixWidth := visibleWidth(prefix)
212+ bodyWidth := width - prefixWidth
213+ if bodyWidth < 1 {
214+ return WrapANSILine(text, width)
215+ }
216+ bodyLines := WrapANSILine(text[bodyStart:], bodyWidth)
217+ continuation := strings.Repeat(" ", prefixWidth)
218+ wrapped := make([]string, len(bodyLines))
219+ for i, bodyLine := range bodyLines {
220+ if i == 0 {
221+ wrapped[i] = prefix + bodyLine
222+ } else {
223+ wrapped[i] = continuation + bodyLine
140224 }
141- if !appendContent(marker, th.Muted) && len(content) > 0 {
142- // appendContent cannot append when the row budget is full, so
143- // replace the final row with the already-padded marker instead.
144- marker = marker + strings.Repeat(" ", max(0, innerWidth-visibleWidth(marker)))
145- content[len(content)-1] = th.FG256(th.Muted, marker)
225+ }
226+ return wrapped
227+}
228+
229+func rightBarChecklistIndent(lines []string) int {
230+ indent := -1
231+ for _, line := range lines {
232+ start, _, _, ok := rightBarChecklistMarker(line)
233+ if ok && (indent < 0 || start < indent) {
234+ indent = start
146235 }
147236 }
237+ return indent
238+}
239+
240+func rightBarChecklistPhaseLine(text string, phaseIndent int) bool {
241+ if phaseIndent < 0 {
242+ return false
243+ }
244+ start, _, _, ok := rightBarChecklistMarker(text)
245+ return ok && start == phaseIndent
246+}
148247
149- border := func(glyph string) string { return th.FG256(th.Muted, glyph) }
150- out := make([]string, 0, height)
151- out = append(out, border("┌")+strings.Repeat("─", innerWidth)+border("┐"))
152- for _, line := range content {
153- out = append(out, border("│")+line+border("│"))
248+func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
249+ _, markerEnd, _, markerOK := rightBarChecklistMarker(text)
250+ if !markerOK {
251+ return 0, 0, false
252+ }
253+ start = markerEnd
254+ if start < len(text) && text[start] == ' ' {
255+ start++
256+ }
257+ trimmedEnd := len(strings.TrimRight(text, " "))
258+ if trimmedEnd <= start {
259+ return 0, 0, false
260+ }
261+ suffix := text[start:trimmedEnd]
262+ separator := strings.LastIndex(suffix, " ")
263+ if separator < 0 {
264+ return 0, 0, false
265+ }
266+ nameEnd := start + separator
267+ progress := strings.TrimSpace(text[nameEnd:trimmedEnd])
268+ if !rightBarProgressToken(progress) {
269+ return 0, 0, false
270+ }
271+ for nameEnd > start && text[nameEnd-1] == ' ' {
272+ nameEnd--
273+ }
274+ if nameEnd <= start {
275+ return 0, 0, false
276+ }
277+ return start, nameEnd, true
278+}
279+
280+func rightBarProgressToken(value string) bool {
281+ separator := strings.IndexByte(value, '/')
282+ if separator <= 0 || separator == len(value)-1 {
283+ return false
284+ }
285+ for _, part := range []string{value[:separator], value[separator+1:]} {
286+ for _, r := range part {
287+ if r < '0' || r > '9' {
288+ return false
289+ }
290+ }
291+ }
292+ return true
293+}
294+
295+func rightBarActivePhaseIndent(lines []string) int {
296+ for _, line := range lines {
297+ start, _, marker, ok := rightBarChecklistMarker(line)
298+ if ok && marker == '>' {
299+ return start
300+ }
301+ }
302+ return -1
303+}
304+
305+func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool {
306+ if activePhaseIndent < 0 {
307+ return false
308+ }
309+ start, _, marker, ok := rightBarChecklistMarker(text)
310+ if !ok {
311+ return false
312+ }
313+ if start == activePhaseIndent {
314+ *activePhase = marker == '>'
315+ return !*activePhase
154316 }
155- for len(out) < height-1 {
156- out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│"))
317+ if start > activePhaseIndent {
318+ return !*activePhase
157319 }
158- out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘"))
159- return out
320+ return false
160321 }
161322
162323 // JoinRightBar combines one main-pane row and one right-bar row without
@@ -57,31 +57,15 @@ func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
57 return available - rightBarWidth, rightBarWidth, true57 return available - rightBarWidth, rightBarWidth, true
58 }58 }
59 59
60-// RenderRightBar renders a bounded, full-height side rail. Widgets are sorted60+// RenderRightBar renders a bounded, full-height side rail without adding a
61-// by extension name and ID so output stays deterministic even when extension61+// second frame around the host-owned separator. Widgets are sorted by
62-// frames arrive concurrently. Content that does not fit is replaced by a62+// extension name and ID so output stays deterministic even when extension
63-// compact marker rather than overflowing the terminal or growing the frame.63+// frames arrive concurrently. Long content wraps to the rail width, and
64+// content that does not fit is replaced by a compact marker.
64 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {65 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
65 if width <= 0 || height <= 0 {66 if width <= 0 || height <= 0 {
66 return nil67 return nil
67 }68 }
68- if width == 1 {
69- return make([]string, height)
70- }
71- if width == 2 {
72- out := make([]string, height)
73- for row := range out {
74- out[row] = strings.Repeat(" ", width)
75- }
76- out[0] = th.FG256(th.Muted, "──")
77- if height > 1 {
78- out[height-1] = th.FG256(th.Muted, "──")
79- }
80- return out
81- }
82- if height == 1 {
83- return []string{th.FG256(th.Muted, strings.Repeat("─", width))}
84- }
85 69
86 ordered := append([]RightBarWidget(nil), widgets...)70 ordered := append([]RightBarWidget(nil), widgets...)
87 sort.SliceStable(ordered, func(a, b int) bool {71 sort.SliceStable(ordered, func(a, b int) bool {
@@ -91,40 +75,91 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
91 return ordered[a].ID < ordered[b].ID75 return ordered[a].ID < ordered[b].ID
92 })76 })
93 77
94- innerWidth := width - 278+ content := make([]string, 0, height)
95- contentRows := height - 2
96- content := make([]string, 0, contentRows)
97 truncated := false79 truncated := false
98- appendContent := func(text string, color int) bool {80+ padLine := func(text string) string {
99- if len(content) >= contentRows {81+ text = truncateToWidth(text, width)
100- truncated = true82+ if visible := visibleWidth(text); visible < width {
101- return false83+ text += strings.Repeat(" ", width-visible)
84+ }
85+ return text
86+ }
87+ paintLine := func(text string, color int, dim, phase bool) string {
88+ text = padLine(text)
89+ if color < 0 {
90+ return text
91+ }
92+ paint := func(value string, valueColor int) string {
93+ if dim {
94+ value = Dim(value)
95+ }
96+ return th.FG256(valueColor, value)
97+ }
98+ if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
99+ if phase {
100+ if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
101+ return paint(text[:markerStart], color) +
102+ paint(text[markerStart:markerEnd], th.Muted) +
103+ paint(text[markerEnd:nameStart], color) +
104+ paint(Bold(text[nameStart:nameEnd]), color) +
105+ paint(text[nameEnd:], color)
106+ }
107+ }
108+ return paint(text[:markerStart], color) +
109+ paint(text[markerStart:markerEnd], th.Muted) +
110+ paint(text[markerEnd:], color)
102 }111 }
103- text = strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ↩ ")112+ if phase {
104- text = truncateToWidth(text, innerWidth)113+ return paint(Bold(text), color)
105- if visible := visibleWidth(text); visible < innerWidth {
106- text += strings.Repeat(" ", innerWidth-visible)
107 }114 }
108- if color >= 0 {115+ return paint(text, color)
109- text = th.FG256(color, text)116+ }
117+ appendContent := func(text string, color int, dim, phase bool) bool {
118+ text = strings.ReplaceAll(text, "\r", "")
119+ for _, physicalLine := range strings.Split(text, "\n") {
120+ wrapped := rightBarWrapLine(physicalLine, width)
121+ if len(wrapped) == 0 {
122+ wrapped = []string{""}
123+ }
124+ for _, line := range wrapped {
125+ if len(content) >= height {
126+ truncated = true
127+ return false
128+ }
129+ content = append(content, paintLine(line, color, dim, phase))
130+ }
110 }131 }
111- content = append(content, text)
112 return true132 return true
113 }133 }
114 134
115 for idx, widget := range ordered {135 for idx, widget := range ordered {
116- if idx > 0 && !appendContent("", -1) {136+ if idx > 0 && !appendContent("", -1, false, false) {
117 break137 break
118 }138 }
119 label := "[" + widget.Extension + "]"139 label := "[" + widget.Extension + "]"
120 if widget.Title != "" {140 if widget.Title != "" {
121 label += " " + widget.Title141 label += " " + widget.Title
122 }142 }
123- if !appendContent(label, th.Accent) {143+ if !appendContent(label, th.Accent, false, false) {
144+ break
145+ }
146+ if !appendContent("", -1, false, false) {
124 break147 break
125 }148 }
149+ activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
150+ if activePhaseIndent >= 0 {
151+ activePhaseIndent++ // host-owned line prefix
152+ }
153+ phaseIndent := rightBarChecklistIndent(widget.Lines)
154+ if phaseIndent >= 0 {
155+ phaseIndent++ // host-owned line prefix
156+ }
157+ activePhase := false
126 for _, line := range widget.Lines {158 for _, line := range widget.Lines {
127- if !appendContent(" "+line, th.FG) {159+ line = " " + line
160+ dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
161+ phase := rightBarChecklistPhaseLine(line, phaseIndent)
162+ if !appendContent(line, th.FG, dim, phase) {
128 break163 break
129 }164 }
130 }165 }
@@ -133,30 +168,156 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
133 }168 }
134 }169 }
135 if truncated {170 if truncated {
136- marker := "... right bar ..."171+ marker := paintLine("... right bar ...", th.Muted, false, false)
137- marker = truncateToWidth(marker, innerWidth)172+ if len(content) == height {
138- if len(content) == contentRows {173+ content[len(content)-1] = marker
139- content[len(content)-1] = ""174+ } else {
175+ content = append(content, marker)
176+ }
177+ }
178+ for len(content) < height {
179+ content = append(content, strings.Repeat(" ", width))
180+ }
181+ return content
182+}
183+
184+func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) {
185+ start = 0
186+ for start < len(text) && text[start] == ' ' {
187+ start++
188+ }
189+ if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' {
190+ return 0, 0, 0, false
191+ }
192+ switch text[start+1] {
193+ case ' ', 'x', 'X', '>':
194+ return start, start + 3, text[start+1], true
195+ default:
196+ return 0, 0, 0, false
197+ }
198+}
199+
200+func rightBarWrapLine(text string, width int) []string {
201+ _, end, _, ok := rightBarChecklistMarker(text)
202+ if !ok {
203+ return WrapANSILine(text, width)
204+ }
205+
206+ bodyStart := end
207+ if bodyStart < len(text) && text[bodyStart] == ' ' {
208+ bodyStart++
209+ }
210+ prefix := text[:bodyStart]
211+ prefixWidth := visibleWidth(prefix)
212+ bodyWidth := width - prefixWidth
213+ if bodyWidth < 1 {
214+ return WrapANSILine(text, width)
215+ }
216+ bodyLines := WrapANSILine(text[bodyStart:], bodyWidth)
217+ continuation := strings.Repeat(" ", prefixWidth)
218+ wrapped := make([]string, len(bodyLines))
219+ for i, bodyLine := range bodyLines {
220+ if i == 0 {
221+ wrapped[i] = prefix + bodyLine
222+ } else {
223+ wrapped[i] = continuation + bodyLine
140 }224 }
141- if !appendContent(marker, th.Muted) && len(content) > 0 {225+ }
142- // appendContent cannot append when the row budget is full, so226+ return wrapped
143- // replace the final row with the already-padded marker instead.227+}
144- marker = marker + strings.Repeat(" ", max(0, innerWidth-visibleWidth(marker)))228+
145- content[len(content)-1] = th.FG256(th.Muted, marker)229+func rightBarChecklistIndent(lines []string) int {
230+ indent := -1
231+ for _, line := range lines {
232+ start, _, _, ok := rightBarChecklistMarker(line)
233+ if ok && (indent < 0 || start < indent) {
234+ indent = start
146 }235 }
147 }236 }
237+ return indent
238+}
239+
240+func rightBarChecklistPhaseLine(text string, phaseIndent int) bool {
241+ if phaseIndent < 0 {
242+ return false
243+ }
244+ start, _, _, ok := rightBarChecklistMarker(text)
245+ return ok && start == phaseIndent
246+}
148 247
149- border := func(glyph string) string { return th.FG256(th.Muted, glyph) }248+func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
150- out := make([]string, 0, height)249+ _, markerEnd, _, markerOK := rightBarChecklistMarker(text)
151- out = append(out, border("┌")+strings.Repeat("─", innerWidth)+border("┐"))250+ if !markerOK {
152- for _, line := range content {251+ return 0, 0, false
153- out = append(out, border("│")+line+border("│"))252+ }
253+ start = markerEnd
254+ if start < len(text) && text[start] == ' ' {
255+ start++
256+ }
257+ trimmedEnd := len(strings.TrimRight(text, " "))
258+ if trimmedEnd <= start {
259+ return 0, 0, false
260+ }
261+ suffix := text[start:trimmedEnd]
262+ separator := strings.LastIndex(suffix, " ")
263+ if separator < 0 {
264+ return 0, 0, false
265+ }
266+ nameEnd := start + separator
267+ progress := strings.TrimSpace(text[nameEnd:trimmedEnd])
268+ if !rightBarProgressToken(progress) {
269+ return 0, 0, false
270+ }
271+ for nameEnd > start && text[nameEnd-1] == ' ' {
272+ nameEnd--
273+ }
274+ if nameEnd <= start {
275+ return 0, 0, false
276+ }
277+ return start, nameEnd, true
278+}
279+
280+func rightBarProgressToken(value string) bool {
281+ separator := strings.IndexByte(value, '/')
282+ if separator <= 0 || separator == len(value)-1 {
283+ return false
284+ }
285+ for _, part := range []string{value[:separator], value[separator+1:]} {
286+ for _, r := range part {
287+ if r < '0' || r > '9' {
288+ return false
289+ }
290+ }
291+ }
292+ return true
293+}
294+
295+func rightBarActivePhaseIndent(lines []string) int {
296+ for _, line := range lines {
297+ start, _, marker, ok := rightBarChecklistMarker(line)
298+ if ok && marker == '>' {
299+ return start
300+ }
301+ }
302+ return -1
303+}
304+
305+func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool {
306+ if activePhaseIndent < 0 {
307+ return false
308+ }
309+ start, _, marker, ok := rightBarChecklistMarker(text)
310+ if !ok {
311+ return false
312+ }
313+ if start == activePhaseIndent {
314+ *activePhase = marker == '>'
315+ return !*activePhase
154 }316 }
155- for len(out) < height-1 {317+ if start > activePhaseIndent {
156- out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│"))318+ return !*activePhase
157 }319 }
158- out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘"))320+ return false
159- return out
160 }321 }
161 322
162 // JoinRightBar combines one main-pane row and one right-bar row without323 // JoinRightBar combines one main-pane row and one right-bar row without
M packages/tui/rightbar_test.go +114 -3
@@ -31,23 +31,134 @@ func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
3131 t.Fatalf("right bar rows = %d, want 7", len(lines))
3232 }
3333 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)
3636 }
3737 }
3838 joined := stripANSI(strings.Join(lines, "\n"))
3939 if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {
4040 t.Fatalf("widgets are not deterministic by extension: %q", joined)
4141 }
42+ if strings.ContainsAny(joined, "┌┐└┘─") {
43+ t.Fatalf("right bar added an outer border: %q", joined)
44+ }
4245
4346 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"}},
4548 }, 24, 7)
4649 if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {
4750 t.Fatalf("truncation marker missing: %q", truncated)
4851 }
4952 }
5053
54+func TestRenderRightBarWrapsLongWidgetLines(t *testing.T) {
55+ lines := RenderRightBar(Dark, []RightBarWidget{{
56+ Extension: "plan",
57+ Lines: []string{"one two three four five six"},
58+ }}, 16, 8)
59+
60+ if len(lines) != 8 {
61+ t.Fatalf("right bar rows = %d, want 8", len(lines))
62+ }
63+ plain := stripANSI(strings.Join(lines, "\n"))
64+ if !strings.Contains(plain, "one two three") || !strings.Contains(plain, "four five six") {
65+ t.Fatalf("long widget line was not wrapped: %q", plain)
66+ }
67+ for idx, line := range lines {
68+ if width := visibleWidth(line); width != 16 {
69+ t.Fatalf("wrapped right bar line %d width = %d, want 16: %q", idx, width, line)
70+ }
71+ }
72+}
73+
74+func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T) {
75+ lines := RenderRightBar(Dark, []RightBarWidget{{
76+ Extension: "tasked-phases",
77+ Title: "p 0/1 | t 0/1",
78+ Lines: []string{
79+ "[>] Copper Lantern Setup 0/1",
80+ " [ ] Sort the leftover star stickers",
81+ },
82+ }}, 36, 8)
83+ plain := stripANSI(strings.Join(lines, "\n"))
84+ for _, want := range []string{
85+ " [>] Copper Lantern Setup 0/1",
86+ " [ ] Sort the leftover star",
87+ " stickers",
88+ } {
89+ if !strings.Contains(plain, want) {
90+ t.Fatalf("right-bar checklist layout missing %q: %q", want, plain)
91+ }
92+ }
93+ if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
94+ t.Fatalf("phase name is not bold: %q", lines)
95+ }
96+ if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
97+ t.Fatalf("task text should not be bold: %q", lines)
98+ }
99+ plainLines := strings.Split(plain, "\n")
100+ if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
101+ t.Fatalf("widget title is not followed by an empty row: %q", plain)
102+ }
103+}
104+
105+func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
106+ lines := RenderRightBar(Dark, []RightBarWidget{{
107+ Extension: "plan",
108+ Lines: []string{"[x] done", "[>] active", "[ ] pending"},
109+ }}, 24, 8)
110+ rendered := strings.Join(lines, "\n")
111+ for _, marker := range []string{"[x]", "[>]", "[ ]"} {
112+ if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) &&
113+ !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) {
114+ t.Fatalf("marker %q was not muted: %q", marker, rendered)
115+ }
116+ }
117+ if !strings.Contains(stripANSI(rendered), " done") {
118+ t.Fatalf("checklist text was not kept readable: %q", rendered)
119+ }
120+}
121+
122+func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
123+ lines := RenderRightBar(Dark, []RightBarWidget{{
124+ Extension: "plan",
125+ Lines: []string{
126+ "[x] finished phase",
127+ " [x] finished task",
128+ "[>] active phase",
129+ " [>] active task",
130+ "[ ] pending phase",
131+ " [ ] pending task",
132+ },
133+ }}, 32, 12)
134+ for _, line := range lines {
135+ if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") {
136+ if strings.Contains(line, "\x1b[2m") {
137+ t.Fatalf("active checklist row was dimmed: %q", line)
138+ }
139+ }
140+ }
141+ plain := stripANSI(strings.Join(lines, "\n"))
142+ for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} {
143+ if !strings.Contains(plain, text) {
144+ t.Fatalf("inactive checklist text %q missing: %q", text, plain)
145+ }
146+ }
147+ if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") {
148+ t.Fatalf("inactive checklist rows were not dimmed: %q", lines)
149+ }
150+}
151+
152+func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
153+ line := JoinRightBar(Dark, "main", "right", 8, 8)
154+ if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) {
155+ t.Fatalf("separator is not muted: %q", line)
156+ }
157+ if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
158+ t.Fatalf("joined right bar added an outer rule: %q", line)
159+ }
160+}
161+
51162 func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
52163 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
53164 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {
@@ -31,23 +31,134 @@ func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
31 t.Fatalf("right bar rows = %d, want 7", len(lines))31 t.Fatalf("right bar rows = %d, want 7", len(lines))
32 }32 }
33 for idx, line := range lines {33 for idx, line := range lines {
34- if width := visibleWidth(line); width > 24 {34+ if width := visibleWidth(line); width != 24 {
35- t.Fatalf("right bar line %d width = %d, want <= 24: %q", idx, width, line)35+ t.Fatalf("right bar line %d width = %d, want 24: %q", idx, width, line)
36 }36 }
37 }37 }
38 joined := stripANSI(strings.Join(lines, "\n"))38 joined := stripANSI(strings.Join(lines, "\n"))
39 if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {39 if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {
40 t.Fatalf("widgets are not deterministic by extension: %q", joined)40 t.Fatalf("widgets are not deterministic by extension: %q", joined)
41 }41 }
42+ if strings.ContainsAny(joined, "┌┐└┘─") {
43+ t.Fatalf("right bar added an outer border: %q", joined)
44+ }
42 45
43 truncated := RenderRightBar(Dark, []RightBarWidget{46 truncated := RenderRightBar(Dark, []RightBarWidget{
44- {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f"}},47+ {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f", "g", "h"}},
45 }, 24, 7)48 }, 24, 7)
46 if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {49 if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {
47 t.Fatalf("truncation marker missing: %q", truncated)50 t.Fatalf("truncation marker missing: %q", truncated)
48 }51 }
49 }52 }
50 53
54+func TestRenderRightBarWrapsLongWidgetLines(t *testing.T) {
55+ lines := RenderRightBar(Dark, []RightBarWidget{{
56+ Extension: "plan",
57+ Lines: []string{"one two three four five six"},
58+ }}, 16, 8)
59+
60+ if len(lines) != 8 {
61+ t.Fatalf("right bar rows = %d, want 8", len(lines))
62+ }
63+ plain := stripANSI(strings.Join(lines, "\n"))
64+ if !strings.Contains(plain, "one two three") || !strings.Contains(plain, "four five six") {
65+ t.Fatalf("long widget line was not wrapped: %q", plain)
66+ }
67+ for idx, line := range lines {
68+ if width := visibleWidth(line); width != 16 {
69+ t.Fatalf("wrapped right bar line %d width = %d, want 16: %q", idx, width, line)
70+ }
71+ }
72+}
73+
74+func TestRenderRightBarUsesChecklistIndentHangingWrapAndBoldPhases(t *testing.T) {
75+ lines := RenderRightBar(Dark, []RightBarWidget{{
76+ Extension: "tasked-phases",
77+ Title: "p 0/1 | t 0/1",
78+ Lines: []string{
79+ "[>] Copper Lantern Setup 0/1",
80+ " [ ] Sort the leftover star stickers",
81+ },
82+ }}, 36, 8)
83+ plain := stripANSI(strings.Join(lines, "\n"))
84+ for _, want := range []string{
85+ " [>] Copper Lantern Setup 0/1",
86+ " [ ] Sort the leftover star",
87+ " stickers",
88+ } {
89+ if !strings.Contains(plain, want) {
90+ t.Fatalf("right-bar checklist layout missing %q: %q", want, plain)
91+ }
92+ }
93+ if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
94+ t.Fatalf("phase name is not bold: %q", lines)
95+ }
96+ if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
97+ t.Fatalf("task text should not be bold: %q", lines)
98+ }
99+ plainLines := strings.Split(plain, "\n")
100+ if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
101+ t.Fatalf("widget title is not followed by an empty row: %q", plain)
102+ }
103+}
104+
105+func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
106+ lines := RenderRightBar(Dark, []RightBarWidget{{
107+ Extension: "plan",
108+ Lines: []string{"[x] done", "[>] active", "[ ] pending"},
109+ }}, 24, 8)
110+ rendered := strings.Join(lines, "\n")
111+ for _, marker := range []string{"[x]", "[>]", "[ ]"} {
112+ if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) &&
113+ !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) {
114+ t.Fatalf("marker %q was not muted: %q", marker, rendered)
115+ }
116+ }
117+ if !strings.Contains(stripANSI(rendered), " done") {
118+ t.Fatalf("checklist text was not kept readable: %q", rendered)
119+ }
120+}
121+
122+func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
123+ lines := RenderRightBar(Dark, []RightBarWidget{{
124+ Extension: "plan",
125+ Lines: []string{
126+ "[x] finished phase",
127+ " [x] finished task",
128+ "[>] active phase",
129+ " [>] active task",
130+ "[ ] pending phase",
131+ " [ ] pending task",
132+ },
133+ }}, 32, 12)
134+ for _, line := range lines {
135+ if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") {
136+ if strings.Contains(line, "\x1b[2m") {
137+ t.Fatalf("active checklist row was dimmed: %q", line)
138+ }
139+ }
140+ }
141+ plain := stripANSI(strings.Join(lines, "\n"))
142+ for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} {
143+ if !strings.Contains(plain, text) {
144+ t.Fatalf("inactive checklist text %q missing: %q", text, plain)
145+ }
146+ }
147+ if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") {
148+ t.Fatalf("inactive checklist rows were not dimmed: %q", lines)
149+ }
150+}
151+
152+func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
153+ line := JoinRightBar(Dark, "main", "right", 8, 8)
154+ if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) {
155+ t.Fatalf("separator is not muted: %q", line)
156+ }
157+ if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
158+ t.Fatalf("joined right bar added an outer rule: %q", line)
159+ }
160+}
161+
51 func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {162 func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
52 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)163 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
53 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {164 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close