detached e507fbe

Merge pull request #30 from bnema/feat/task-phases-rightbar

feat(tui): add right sidebar toggle and compact task chrome
commit
e507fbed6775c5d2b29ad469f4175bf140c206af
author
bnema · · 2026-08-05 14:49 UTC
committer
GitHub · · 2026-08-05 14:49 UTC
merge of
b16bf8d + 251604c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +796 -186
M README.md +1 -0
@@ -888,6 +888,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
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 or narrow widgets use a bounded above-input fallback. |
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 or narrow widgets use a bounded above-input fallback. |
891 | `ctrl+l` | Redraw the screen. |892 | `ctrl+l` | Redraw the screen. |
892 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |893 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
893 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). |894 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). |
M docs/extensions.md +4 -8
@@ -426,11 +426,10 @@ hint with these interactive-zot values:
426426 - `"right_bar"` keeps the widget in a display-only side rail beside the
427427 transcript when the terminal is wide enough.
428428
429-The right bar is host-owned: it bounds width and height, orders widgets by
430-extension and ID, and truncates content that does not fit. On narrow terminals
431-it falls back automatically to `above_input`. Empty or unknown positions retain
432-the historical `above_input` behavior so older extensions remain compatible.
433-Use `open_panel` when a widget needs interaction or navigation.
429+The host owns right-bar layout: it orders widgets, bounds width and height,
430+and truncates content. Narrow terminals and `Ctrl+B` use a bounded
431+`above_input` fallback. Empty or unknown positions keep the historical
432+`above_input` behavior. Use `open_panel` for interaction or navigation.
434433
435434 ```json
436435 {"type":"widget","id":"plan","position":"right_bar",
@@ -723,9 +722,6 @@ e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines)
723722 e.ClearWidget("plan")
724723 ```
725724
726-`right_bar` widgets are display-only in the first version. Open an extension
727-panel for editing or keyboard navigation.
728-
729725 The SDK has four interceptor hooks, all optional:
730726
731727 ```go
@@ -426,11 +426,10 @@ hint with these interactive-zot values:
426 - `"right_bar"` keeps the widget in a display-only side rail beside the426 - `"right_bar"` keeps the widget in a display-only side rail beside the
427 transcript when the terminal is wide enough.427 transcript when the terminal is wide enough.
428 428
429-The right bar is host-owned: it bounds width and height, orders widgets by429+The host owns right-bar layout: it orders widgets, bounds width and height,
430-extension and ID, and truncates content that does not fit. On narrow terminals430+and truncates content. Narrow terminals and `Ctrl+B` use a bounded
431-it falls back automatically to `above_input`. Empty or unknown positions retain431+`above_input` fallback. Empty or unknown positions keep the historical
432-the historical `above_input` behavior so older extensions remain compatible.432+`above_input` behavior. Use `open_panel` for interaction or navigation.
433-Use `open_panel` when a widget needs interaction or navigation.
434 433
435 ```json434 ```json
436 {"type":"widget","id":"plan","position":"right_bar",435 {"type":"widget","id":"plan","position":"right_bar",
@@ -723,9 +722,6 @@ e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines)
723 e.ClearWidget("plan")722 e.ClearWidget("plan")
724 ```723 ```
725 724
726-`right_bar` widgets are display-only in the first version. Open an extension
727-panel for editing or keyboard navigation.
728-
729 The SDK has four interceptor hooks, all optional:725 The SDK has four interceptor hooks, all optional:
730 726
731 ```go727 ```go
M examples/extensions/tasked-phases/README.md +8 -4
@@ -18,6 +18,10 @@ set_current_phase set_task_checked set_phase_checked
1818 clear
1919 ```
2020
21+Successful mutations return one progress line; use `get_status` or
22+`replace_plan` for details. `set_task_checked` changes one task;
23+`set_phase_checked` changes every task in a phase.
24+
2125 ## Build and run
2226
2327 From the zot repository:
@@ -63,7 +67,7 @@ as it is completed, and advance the current phase when work moves forward.
6367 Completed phases and future phases are not repeated in every provider request;
6468 use `get_status` or `/phases` when the full checklist is needed.
6569
66-When a plan is active, the extension publishes its compact checklist through the
67-host's generic `right_bar` widget position. zot keeps it beside the transcript on
68-wide terminals and automatically falls back to the normal above-input widget on
69-narrow terminals. The extension does not implement any terminal layout code.
70+Active plans publish `p done/total | t done/total` and checklist rows through
71+`right_bar`. The host dims inactive phases, uses a bounded above-input fallback
72+on narrow terminals or when `Ctrl+B` hides the rail, and clears the chrome when
73+the plan is complete. Use `get_status` or `/phases` for full details.
@@ -18,6 +18,10 @@ set_current_phase set_task_checked set_phase_checked
18 clear18 clear
19 ```19 ```
20 20
21+Successful mutations return one progress line; use `get_status` or
22+`replace_plan` for details. `set_task_checked` changes one task;
23+`set_phase_checked` changes every task in a phase.
24+
21 ## Build and run25 ## Build and run
22 26
23 From the zot repository:27 From the zot repository:
@@ -63,7 +67,7 @@ as it is completed, and advance the current phase when work moves forward.
63 Completed phases and future phases are not repeated in every provider request;67 Completed phases and future phases are not repeated in every provider request;
64 use `get_status` or `/phases` when the full checklist is needed.68 use `get_status` or `/phases` when the full checklist is needed.
65 69
66-When a plan is active, the extension publishes its compact checklist through the70+Active plans publish `p done/total | t done/total` and checklist rows through
67-host's generic `right_bar` widget position. zot keeps it beside the transcript on71+`right_bar`. The host dims inactive phases, uses a bounded above-input fallback
68-wide terminals and automatically falls back to the normal above-input widget on72+on narrow terminals or when `Ctrl+B` hides the rail, and clears the chrome when
69-narrow terminals. The extension does not implement any terminal layout code.73+the plan is complete. Use `get_status` or `/phases` for full details.
M examples/extensions/tasked-phases/main.go +117 -25
@@ -4,7 +4,6 @@ import (
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 +4 -0
@@ -18,6 +18,10 @@ 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+Successful mutations return one progress line; use `get_status` or
22+`replace_plan` for details. `set_task_checked` changes one task;
23+`set_phase_checked` changes every task in a phase.
24+
2125 ## Planning rules
2226
2327 - Prefer 3–7 phases unless the task is tiny.
@@ -18,6 +18,10 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren
18 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.18 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.19 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.
20 20
21+Successful mutations return one progress line; use `get_status` or
22+`replace_plan` for details. `set_task_checked` changes one task;
23+`set_phase_checked` changes every task in a phase.
24+
21 ## Planning rules25 ## Planning rules
22 26
23 - Prefer 3–7 phases unless the task is tiny.27 - Prefer 3–7 phases unless the task is tiny.
M examples/extensions/tasked-phases/state.go +5 -49
@@ -774,7 +774,6 @@ func checkedWord(checked bool) string {
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 +4 -0
@@ -18,6 +18,10 @@ 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+Successful mutations return one progress line; use `get_status` or
22+`replace_plan` for details. `set_task_checked` changes one task;
23+`set_phase_checked` changes every task in a phase.
24+
2125 ## Planning rules
2226
2327 - Prefer 3–7 phases unless the task is tiny.
@@ -18,6 +18,10 @@ Use the `tasked_phases` tool as the source of truth for the spec, phases, curren
18 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.18 7. Use `set_phase_checked` to complete or reopen every task in a phase. The phase must already contain at least one task.
19 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.19 8. When every task is checked, treat the plan as closed. For unrelated work, call `clear` to discard the plan, or call `set_spec` immediately followed by `replace_plan` to record a new spec and a new set of phases.
20 20
21+Successful mutations return one progress line; use `get_status` or
22+`replace_plan` for details. `set_task_checked` changes one task;
23+`set_phase_checked` changes every task in a phase.
24+
21 ## Planning rules25 ## Planning rules
22 26
23 - Prefer 3–7 phases unless the task is tiny.27 - Prefer 3–7 phases unless the task is tiny.
M packages/agent/modes/ext_notes_test.go +52 -1
@@ -1,6 +1,7 @@
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
@@ -518,6 +518,7 @@ type Interactive struct {
518518 reloadStatusSeq uint64
519519 extStatuses map[string]map[string]extensionStatus
520520 extWidgets map[string]map[string]extensionWidget
521+ rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
521522 liveBlock []string // live streaming/tool progress rendered outside scrollback
522523 helpBlock []string // rendered above the chat when /help was typed
523524 cumUsage provider.Usage
@@ -1239,6 +1240,10 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
12391240 const maxExtensionWidgetRows = 12
12401241 const maxExtensionStatusRows = 6
12411242
1243+// maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back
1244+// above the input, leaving one row for the truncation marker.
1245+const maxNarrowExtensionChromeRows = 6
1246+
12421247 func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
12431248 var out []tui.RightBarWidget
12441249 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
@@ -1262,6 +1267,17 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
12621267 return i.extensionChromeLinesAtLocked(cols, false)
12631268 }
12641269
1270+func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
1271+ lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
1272+ if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
1273+ return lines
1274+ }
1275+ limit := maxNarrowExtensionChromeRows
1276+ trimmed := append([]string(nil), lines[:limit-1]...)
1277+ trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1278+ return trimmed
1279+}
1280+
12651281 func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
12661282 var out []string
12671283 bodyWidth := cols - 2
@@ -1595,13 +1611,14 @@ func (i *Interactive) redraw() {
15951611 rightBarWidgets := i.rightBarWidgetsLocked()
15961612 rightBarActive := false
15971613 var rightBarWidth int
1598- if len(rightBarWidgets) > 0 {
1614+ if !i.rightBarHidden && len(rightBarWidgets) > 0 {
15991615 if width, rail, ok := tui.RightBarColumns(cols); ok {
16001616 mainCols = width
16011617 rightBarWidth = rail
16021618 rightBarActive = true
16031619 }
16041620 }
1621+ rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
16051622 chat := i.cachedChatLocked(mainCols)
16061623
16071624 // Dialogs (login or model picker) render between chat and the editor.
@@ -1838,7 +1855,7 @@ func (i *Interactive) redraw() {
18381855 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
18391856 }
18401857
1841- extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive)
1858+ extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
18421859
18431860 // Bottom-sticky sections (always visible, never scroll). Each
18441861 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -2925,6 +2942,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
29252942 if i.ed.IsEmpty() && !i.busy {
29262943 return true
29272944 }
2945+ case tui.KeyCtrlB:
2946+ i.mu.Lock()
2947+ i.rightBarHidden = !i.rightBarHidden
2948+ i.mu.Unlock()
2949+ i.rend.Invalidate()
2950+ i.invalidate()
2951+ return false
29282952 case tui.KeyCtrlL:
29292953 i.rend.Clear()
29302954 i.invalidate()
@@ -518,6 +518,7 @@ type Interactive struct {
518 reloadStatusSeq uint64518 reloadStatusSeq uint64
519 extStatuses map[string]map[string]extensionStatus519 extStatuses map[string]map[string]extensionStatus
520 extWidgets map[string]map[string]extensionWidget520 extWidgets map[string]map[string]extensionWidget
521+ rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
521 liveBlock []string // live streaming/tool progress rendered outside scrollback522 liveBlock []string // live streaming/tool progress rendered outside scrollback
522 helpBlock []string // rendered above the chat when /help was typed523 helpBlock []string // rendered above the chat when /help was typed
523 cumUsage provider.Usage524 cumUsage provider.Usage
@@ -1239,6 +1240,10 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
1239 const maxExtensionWidgetRows = 121240 const maxExtensionWidgetRows = 12
1240 const maxExtensionStatusRows = 61241 const maxExtensionStatusRows = 6
1241 1242
1243+// maxNarrowExtensionChromeRows bounds right-bar widgets when they fall back
1244+// above the input, leaving one row for the truncation marker.
1245+const maxNarrowExtensionChromeRows = 6
1246+
1242 func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {1247 func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1243 var out []tui.RightBarWidget1248 var out []tui.RightBarWidget
1244 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {1249 for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
@@ -1262,6 +1267,17 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1262 return i.extensionChromeLinesAtLocked(cols, false)1267 return i.extensionChromeLinesAtLocked(cols, false)
1263 }1268 }
1264 1269
1270+func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
1271+ lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
1272+ if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
1273+ return lines
1274+ }
1275+ limit := maxNarrowExtensionChromeRows
1276+ trimmed := append([]string(nil), lines[:limit-1]...)
1277+ trimmed = append(trimmed, i.cfg.Theme.FG256(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1278+ return trimmed
1279+}
1280+
1265 func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {1281 func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
1266 var out []string1282 var out []string
1267 bodyWidth := cols - 21283 bodyWidth := cols - 2
@@ -1595,13 +1611,14 @@ func (i *Interactive) redraw() {
1595 rightBarWidgets := i.rightBarWidgetsLocked()1611 rightBarWidgets := i.rightBarWidgetsLocked()
1596 rightBarActive := false1612 rightBarActive := false
1597 var rightBarWidth int1613 var rightBarWidth int
1598- if len(rightBarWidgets) > 0 {1614+ if !i.rightBarHidden && len(rightBarWidgets) > 0 {
1599 if width, rail, ok := tui.RightBarColumns(cols); ok {1615 if width, rail, ok := tui.RightBarColumns(cols); ok {
1600 mainCols = width1616 mainCols = width
1601 rightBarWidth = rail1617 rightBarWidth = rail
1602 rightBarActive = true1618 rightBarActive = true
1603 }1619 }
1604 }1620 }
1621+ rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1605 chat := i.cachedChatLocked(mainCols)1622 chat := i.cachedChatLocked(mainCols)
1606 1623
1607 // Dialogs (login or model picker) render between chat and the editor.1624 // Dialogs (login or model picker) render between chat and the editor.
@@ -1838,7 +1855,7 @@ func (i *Interactive) redraw() {
1838 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))1855 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1839 }1856 }
1840 1857
1841- extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive)1858+ extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
1842 1859
1843 // Bottom-sticky sections (always visible, never scroll). Each1860 // Bottom-sticky sections (always visible, never scroll). Each
1844 // non-empty subsection (dialog, suggest popup, sliding-in queue)1861 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -2925,6 +2942,13 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2925 if i.ed.IsEmpty() && !i.busy {2942 if i.ed.IsEmpty() && !i.busy {
2926 return true2943 return true
2927 }2944 }
2945+ case tui.KeyCtrlB:
2946+ i.mu.Lock()
2947+ i.rightBarHidden = !i.rightBarHidden
2948+ i.mu.Unlock()
2949+ i.rend.Invalidate()
2950+ i.invalidate()
2951+ return false
2928 case tui.KeyCtrlL:2952 case tui.KeyCtrlL:
2929 i.rend.Clear()2953 i.rend.Clear()
2930 i.invalidate()2954 i.invalidate()
M packages/core/title.go +3 -0
@@ -76,6 +76,9 @@ func GenerateSessionTitle(ctx context.Context, client provider.Client, model, pr
7676 return "", fmt.Errorf("title generation stream: %w", finalErr)
7777 }
7878 if !done {
79+ if err := ctx.Err(); err != nil {
80+ return "", fmt.Errorf("title generation stream: %w", err)
81+ }
7982 return "", fmt.Errorf("title generation stream ended without a result")
8083 }
8184 if text.Len() == 0 {
@@ -76,6 +76,9 @@ func GenerateSessionTitle(ctx context.Context, client provider.Client, model, pr
76 return "", fmt.Errorf("title generation stream: %w", finalErr)76 return "", fmt.Errorf("title generation stream: %w", finalErr)
77 }77 }
78 if !done {78 if !done {
79+ if err := ctx.Err(); err != nil {
80+ return "", fmt.Errorf("title generation stream: %w", err)
81+ }
79 return "", fmt.Errorf("title generation stream ended without a result")82 return "", fmt.Errorf("title generation stream ended without a result")
80 }83 }
81 if text.Len() == 0 {84 if text.Len() == 0 {
M packages/tui/input.go +5 -0
@@ -41,6 +41,7 @@ const (
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/render.go +12 -7
@@ -37,12 +37,13 @@ type Renderer struct {
3737 // in the terminal's visible viewport so we can diff safely, and bail
3838 // out to clear+replay when the diff would touch rows that are no
3939 // longer addressable.
40- logChat []string
41- logBottom []string
42- logLines []string
43- logViewportTop int
44- logHardwareRow int
45- logInit bool
40+ logChat []string
41+ logBottom []string
42+ logLines []string
43+ logViewportTop int
44+ logHardwareRow int
45+ logInit bool
46+ logNeedsFullRepaint bool
4647
4748 // keepScrollback is true when we must NOT emit \x1b[3J
4849 // (erase-in-display 3, "clear scrollback").
@@ -588,8 +589,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
588589 wasInitialized := r.logInit
589590 full := !wasInitialized || len(r.logLines) == 0
590591 if full {
591- writeFull(true, !wasInitialized)
592+ // Returning from the fixed right-bar frame needs a full viewport
593+ // repaint, but it must not discard terminal scrollback merely because
594+ // the renderer's flow cache is intentionally empty.
595+ writeFull(true, !wasInitialized && !r.logNeedsFullRepaint)
592596 r.logInit = true
597+ r.logNeedsFullRepaint = false
593598 } else {
594599 firstChanged := -1
595600 lastChanged := -1
@@ -37,12 +37,13 @@ type Renderer struct {
37 // in the terminal's visible viewport so we can diff safely, and bail37 // in the terminal's visible viewport so we can diff safely, and bail
38 // out to clear+replay when the diff would touch rows that are no38 // out to clear+replay when the diff would touch rows that are no
39 // longer addressable.39 // longer addressable.
40- logChat []string40+ logChat []string
41- logBottom []string41+ logBottom []string
42- logLines []string42+ logLines []string
43- logViewportTop int43+ logViewportTop int
44- logHardwareRow int44+ logHardwareRow int
45- logInit bool45+ logInit bool
46+ logNeedsFullRepaint bool
46 47
47 // keepScrollback is true when we must NOT emit \x1b[3J48 // keepScrollback is true when we must NOT emit \x1b[3J
48 // (erase-in-display 3, "clear scrollback").49 // (erase-in-display 3, "clear scrollback").
@@ -588,8 +589,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
588 wasInitialized := r.logInit589 wasInitialized := r.logInit
589 full := !wasInitialized || len(r.logLines) == 0590 full := !wasInitialized || len(r.logLines) == 0
590 if full {591 if full {
591- writeFull(true, !wasInitialized)592+ // Returning from the fixed right-bar frame needs a full viewport
593+ // repaint, but it must not discard terminal scrollback merely because
594+ // the renderer's flow cache is intentionally empty.
595+ writeFull(true, !wasInitialized && !r.logNeedsFullRepaint)
592 r.logInit = true596 r.logInit = true
597+ r.logNeedsFullRepaint = false
593 } else {598 } else {
594 firstChanged := -1599 firstChanged := -1
595 lastChanged := -1600 lastChanged := -1
M packages/tui/rightbar.go +232 -57
@@ -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 lines are clipped to the rail width with
64+// a three-dot ellipsis.
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,89 @@ 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, phase bool) string {
81+ if phase {
82+ text = truncateRightBarPhaseLine(text, width)
83+ } else {
84+ text = truncateRightBarLine(text, width)
85+ }
86+ if visible := visibleWidth(text); visible < width {
87+ text += strings.Repeat(" ", width-visible)
88+ }
89+ return text
90+ }
91+ paintLine := func(text string, color int, dim, phase bool) string {
92+ text = padLine(text, phase)
93+ if color < 0 {
94+ return text
95+ }
96+ paint := func(value string, valueColor int) string {
97+ if dim {
98+ value = Dim(value)
99+ }
100+ return th.FG256(valueColor, value)
101+ }
102+ if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
103+ if phase {
104+ if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
105+ return paint(text[:markerStart], color) +
106+ paint(text[markerStart:markerEnd], th.Muted) +
107+ paint(text[markerEnd:nameStart], color) +
108+ paint(Bold(text[nameStart:nameEnd]), color) +
109+ paint(text[nameEnd:], color)
110+ }
111+ }
112+ return paint(text[:markerStart], color) +
113+ paint(text[markerStart:markerEnd], th.Muted) +
114+ paint(text[markerEnd:], color)
102115 }
103- text = strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ↩ ")
104- text = truncateToWidth(text, innerWidth)
105- if visible := visibleWidth(text); visible < innerWidth {
106- text += strings.Repeat(" ", innerWidth-visible)
116+ if phase {
117+ return paint(Bold(text), color)
107118 }
108- if color >= 0 {
109- text = th.FG256(color, text)
119+ return paint(text, color)
120+ }
121+ appendContent := func(text string, color int, dim, phase bool) bool {
122+ text = strings.ReplaceAll(text, "\r", "")
123+ for _, line := range strings.Split(text, "\n") {
124+ if len(content) >= height {
125+ truncated = true
126+ return false
127+ }
128+ content = append(content, paintLine(line, color, dim, phase))
110129 }
111- content = append(content, text)
112130 return true
113131 }
114132
115133 for idx, widget := range ordered {
116- if idx > 0 && !appendContent("", -1) {
134+ if idx > 0 && !appendContent("", -1, false, false) {
117135 break
118136 }
119137 label := "[" + widget.Extension + "]"
120138 if widget.Title != "" {
121139 label += " " + widget.Title
122140 }
123- if !appendContent(label, th.Accent) {
141+ if !appendContent(label, th.Accent, false, false) {
124142 break
125143 }
144+ if !appendContent("", -1, false, false) {
145+ break
146+ }
147+ activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
148+ if activePhaseIndent >= 0 {
149+ activePhaseIndent++ // host-owned line prefix
150+ }
151+ phaseIndent := rightBarChecklistIndent(widget.Lines)
152+ if phaseIndent >= 0 {
153+ phaseIndent++ // host-owned line prefix
154+ }
155+ activePhase := false
126156 for _, line := range widget.Lines {
127- if !appendContent(" "+line, th.FG) {
157+ line = " " + line
158+ dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
159+ phase := rightBarChecklistPhaseLine(line, phaseIndent)
160+ if !appendContent(line, th.FG, dim, phase) {
128161 break
129162 }
130163 }
@@ -133,30 +166,170 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
133166 }
134167 }
135168 if truncated {
136- marker := "... right bar ..."
137- marker = truncateToWidth(marker, innerWidth)
138- if len(content) == contentRows {
139- content[len(content)-1] = ""
169+ marker := paintLine("... right bar ...", th.Muted, false, false)
170+ if len(content) == height {
171+ content[len(content)-1] = marker
172+ } else {
173+ content = append(content, marker)
140174 }
141- if !appendContent(marker, th.Muted) && len(content) > 0 {
142- // appendContent cannot append when the row budget is full, so
143- // replace the final row with the already-padded marker instead.
144- marker = marker + strings.Repeat(" ", max(0, innerWidth-visibleWidth(marker)))
145- content[len(content)-1] = th.FG256(th.Muted, marker)
175+ }
176+ for len(content) < height {
177+ content = append(content, strings.Repeat(" ", width))
178+ }
179+ return content
180+}
181+
182+func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) {
183+ start = 0
184+ for start < len(text) && text[start] == ' ' {
185+ start++
186+ }
187+ if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' {
188+ return 0, 0, 0, false
189+ }
190+ switch text[start+1] {
191+ case ' ', 'x', 'X', '>':
192+ return start, start + 3, text[start+1], true
193+ default:
194+ return 0, 0, 0, false
195+ }
196+}
197+
198+func truncateRightBarLine(text string, width int) string {
199+ if width <= 0 || visibleWidth(text) <= width {
200+ return text
201+ }
202+ if width <= 3 {
203+ return truncateToWidth("...", width)
204+ }
205+ return truncateToWidth(text, width-3) + "..."
206+}
207+
208+func truncateRightBarPhaseLine(text string, width int) string {
209+ if width <= 0 || visibleWidth(text) <= width {
210+ return text
211+ }
212+ nameStart, nameEnd, ok := rightBarChecklistPhaseName(text)
213+ if !ok {
214+ return truncateRightBarLine(text, width)
215+ }
216+ prefix := text[:nameStart]
217+ suffix := text[nameEnd:]
218+ nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix)
219+ if nameWidth <= 0 {
220+ return truncateRightBarLine(text, width)
221+ }
222+ return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix
223+}
224+
225+func rightBarChecklistIndent(lines []string) int {
226+ indent := -1
227+ for _, line := range lines {
228+ start, _, _, ok := rightBarChecklistMarker(line)
229+ if ok && (indent < 0 || start < indent) {
230+ indent = start
231+ }
232+ }
233+ return indent
234+}
235+
236+func rightBarChecklistPhaseLine(text string, phaseIndent int) bool {
237+ if phaseIndent < 0 {
238+ return false
239+ }
240+ start, _, _, ok := rightBarChecklistMarker(text)
241+ return ok && start == phaseIndent
242+}
243+
244+func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
245+ _, markerEnd, _, markerOK := rightBarChecklistMarker(text)
246+ if !markerOK {
247+ return 0, 0, false
248+ }
249+ start = markerEnd
250+ if start < len(text) && text[start] == ' ' {
251+ start++
252+ }
253+ trimmedEnd := len(strings.TrimRight(text, " "))
254+ if trimmedEnd <= start {
255+ return 0, 0, false
256+ }
257+ suffix := text[start:trimmedEnd]
258+ separator := strings.LastIndex(suffix, " ")
259+ if separator < 0 {
260+ return rightBarFallbackPhaseName(text, start, trimmedEnd)
261+ }
262+ nameEnd := start + separator
263+ progress := strings.TrimSpace(text[nameEnd:trimmedEnd])
264+ if !rightBarProgressToken(progress) {
265+ return rightBarFallbackPhaseName(text, start, trimmedEnd)
266+ }
267+ for nameEnd > start && text[nameEnd-1] == ' ' {
268+ nameEnd--
269+ }
270+ if nameEnd <= start {
271+ return 0, 0, false
272+ }
273+ return start, nameEnd, true
274+}
275+
276+func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) {
277+ for end > start && text[end-1] == ' ' {
278+ end--
279+ }
280+ if end-start >= 3 && text[end-3:end] == "..." {
281+ end -= 3
282+ }
283+ for end > start && text[end-1] == ' ' {
284+ end--
285+ }
286+ if end <= start {
287+ return 0, 0, false
288+ }
289+ return start, end, true
290+}
291+
292+func rightBarProgressToken(value string) bool {
293+ separator := strings.IndexByte(value, '/')
294+ if separator <= 0 || separator == len(value)-1 {
295+ return false
296+ }
297+ for _, part := range []string{value[:separator], value[separator+1:]} {
298+ for _, r := range part {
299+ if r < '0' || r > '9' {
300+ return false
301+ }
146302 }
147303 }
304+ return true
305+}
306+
307+func rightBarActivePhaseIndent(lines []string) int {
308+ for _, line := range lines {
309+ start, _, marker, ok := rightBarChecklistMarker(line)
310+ if ok && marker == '>' {
311+ return start
312+ }
313+ }
314+ return -1
315+}
148316
149- border := func(glyph string) string { return th.FG256(th.Muted, glyph) }
150- out := make([]string, 0, height)
151- out = append(out, border("┌")+strings.Repeat("─", innerWidth)+border("┐"))
152- for _, line := range content {
153- out = append(out, border("│")+line+border("│"))
317+func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool {
318+ if activePhaseIndent < 0 {
319+ return false
320+ }
321+ start, _, marker, ok := rightBarChecklistMarker(text)
322+ if !ok {
323+ return false
324+ }
325+ if start == activePhaseIndent {
326+ *activePhase = marker == '>'
327+ return !*activePhase
154328 }
155- for len(out) < height-1 {
156- out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│"))
329+ if start > activePhaseIndent {
330+ return !*activePhase
157331 }
158- out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘"))
159- return out
332+ return false
160333 }
161334
162335 // JoinRightBar combines one main-pane row and one right-bar row without
@@ -228,12 +401,14 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow
228401
229402 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
230403 // later fallback to DrawLog starts with a full repaint instead of trying
231- // to diff against logical rows from before the rail was enabled.
404+ // to diff against logical rows from before the rail was enabled. Mark the
405+ // transition separately so that repaint does not clear scrollback.
232406 r.logChat = nil
233407 r.logBottom = nil
234408 r.logLines = nil
235409 r.logViewportTop = 0
236410 r.logHardwareRow = 0
237411 r.logInit = false
412+ r.logNeedsFullRepaint = true
238413 r.Draw(frame, cursorRow, cursorCol)
239414 }
@@ -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 lines are clipped to the rail width with
64+// a three-dot ellipsis.
64 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {65 func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
65 if width <= 0 || height <= 0 {66 if width <= 0 || height <= 0 {
66 return 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,89 @@ 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, phase bool) string {
99- if len(content) >= contentRows {81+ if phase {
100- truncated = true82+ text = truncateRightBarPhaseLine(text, width)
101- return false83+ } else {
84+ text = truncateRightBarLine(text, width)
85+ }
86+ if visible := visibleWidth(text); visible < width {
87+ text += strings.Repeat(" ", width-visible)
88+ }
89+ return text
90+ }
91+ paintLine := func(text string, color int, dim, phase bool) string {
92+ text = padLine(text, phase)
93+ if color < 0 {
94+ return text
95+ }
96+ paint := func(value string, valueColor int) string {
97+ if dim {
98+ value = Dim(value)
99+ }
100+ return th.FG256(valueColor, value)
101+ }
102+ if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
103+ if phase {
104+ if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
105+ return paint(text[:markerStart], color) +
106+ paint(text[markerStart:markerEnd], th.Muted) +
107+ paint(text[markerEnd:nameStart], color) +
108+ paint(Bold(text[nameStart:nameEnd]), color) +
109+ paint(text[nameEnd:], color)
110+ }
111+ }
112+ return paint(text[:markerStart], color) +
113+ paint(text[markerStart:markerEnd], th.Muted) +
114+ paint(text[markerEnd:], color)
102 }115 }
103- text = strings.ReplaceAll(strings.ReplaceAll(text, "\r", ""), "\n", " ↩ ")116+ if phase {
104- text = truncateToWidth(text, innerWidth)117+ return paint(Bold(text), color)
105- if visible := visibleWidth(text); visible < innerWidth {
106- text += strings.Repeat(" ", innerWidth-visible)
107 }118 }
108- if color >= 0 {119+ return paint(text, color)
109- text = th.FG256(color, text)120+ }
121+ appendContent := func(text string, color int, dim, phase bool) bool {
122+ text = strings.ReplaceAll(text, "\r", "")
123+ for _, line := range strings.Split(text, "\n") {
124+ if len(content) >= height {
125+ truncated = true
126+ return false
127+ }
128+ content = append(content, paintLine(line, color, dim, phase))
110 }129 }
111- content = append(content, text)
112 return true130 return true
113 }131 }
114 132
115 for idx, widget := range ordered {133 for idx, widget := range ordered {
116- if idx > 0 && !appendContent("", -1) {134+ if idx > 0 && !appendContent("", -1, false, false) {
117 break135 break
118 }136 }
119 label := "[" + widget.Extension + "]"137 label := "[" + widget.Extension + "]"
120 if widget.Title != "" {138 if widget.Title != "" {
121 label += " " + widget.Title139 label += " " + widget.Title
122 }140 }
123- if !appendContent(label, th.Accent) {141+ if !appendContent(label, th.Accent, false, false) {
124 break142 break
125 }143 }
144+ if !appendContent("", -1, false, false) {
145+ break
146+ }
147+ activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
148+ if activePhaseIndent >= 0 {
149+ activePhaseIndent++ // host-owned line prefix
150+ }
151+ phaseIndent := rightBarChecklistIndent(widget.Lines)
152+ if phaseIndent >= 0 {
153+ phaseIndent++ // host-owned line prefix
154+ }
155+ activePhase := false
126 for _, line := range widget.Lines {156 for _, line := range widget.Lines {
127- if !appendContent(" "+line, th.FG) {157+ line = " " + line
158+ dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
159+ phase := rightBarChecklistPhaseLine(line, phaseIndent)
160+ if !appendContent(line, th.FG, dim, phase) {
128 break161 break
129 }162 }
130 }163 }
@@ -133,30 +166,170 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
133 }166 }
134 }167 }
135 if truncated {168 if truncated {
136- marker := "... right bar ..."169+ marker := paintLine("... right bar ...", th.Muted, false, false)
137- marker = truncateToWidth(marker, innerWidth)170+ if len(content) == height {
138- if len(content) == contentRows {171+ content[len(content)-1] = marker
139- content[len(content)-1] = ""172+ } else {
173+ content = append(content, marker)
140 }174 }
141- if !appendContent(marker, th.Muted) && len(content) > 0 {175+ }
142- // appendContent cannot append when the row budget is full, so176+ for len(content) < height {
143- // replace the final row with the already-padded marker instead.177+ content = append(content, strings.Repeat(" ", width))
144- marker = marker + strings.Repeat(" ", max(0, innerWidth-visibleWidth(marker)))178+ }
145- content[len(content)-1] = th.FG256(th.Muted, marker)179+ return content
180+}
181+
182+func rightBarChecklistMarker(text string) (start, end int, marker byte, ok bool) {
183+ start = 0
184+ for start < len(text) && text[start] == ' ' {
185+ start++
186+ }
187+ if len(text) < start+3 || text[start] != '[' || text[start+2] != ']' {
188+ return 0, 0, 0, false
189+ }
190+ switch text[start+1] {
191+ case ' ', 'x', 'X', '>':
192+ return start, start + 3, text[start+1], true
193+ default:
194+ return 0, 0, 0, false
195+ }
196+}
197+
198+func truncateRightBarLine(text string, width int) string {
199+ if width <= 0 || visibleWidth(text) <= width {
200+ return text
201+ }
202+ if width <= 3 {
203+ return truncateToWidth("...", width)
204+ }
205+ return truncateToWidth(text, width-3) + "..."
206+}
207+
208+func truncateRightBarPhaseLine(text string, width int) string {
209+ if width <= 0 || visibleWidth(text) <= width {
210+ return text
211+ }
212+ nameStart, nameEnd, ok := rightBarChecklistPhaseName(text)
213+ if !ok {
214+ return truncateRightBarLine(text, width)
215+ }
216+ prefix := text[:nameStart]
217+ suffix := text[nameEnd:]
218+ nameWidth := width - visibleWidth(prefix) - visibleWidth(suffix)
219+ if nameWidth <= 0 {
220+ return truncateRightBarLine(text, width)
221+ }
222+ return prefix + truncateRightBarLine(text[nameStart:nameEnd], nameWidth) + suffix
223+}
224+
225+func rightBarChecklistIndent(lines []string) int {
226+ indent := -1
227+ for _, line := range lines {
228+ start, _, _, ok := rightBarChecklistMarker(line)
229+ if ok && (indent < 0 || start < indent) {
230+ indent = start
231+ }
232+ }
233+ return indent
234+}
235+
236+func rightBarChecklistPhaseLine(text string, phaseIndent int) bool {
237+ if phaseIndent < 0 {
238+ return false
239+ }
240+ start, _, _, ok := rightBarChecklistMarker(text)
241+ return ok && start == phaseIndent
242+}
243+
244+func rightBarChecklistPhaseName(text string) (start, end int, ok bool) {
245+ _, markerEnd, _, markerOK := rightBarChecklistMarker(text)
246+ if !markerOK {
247+ return 0, 0, false
248+ }
249+ start = markerEnd
250+ if start < len(text) && text[start] == ' ' {
251+ start++
252+ }
253+ trimmedEnd := len(strings.TrimRight(text, " "))
254+ if trimmedEnd <= start {
255+ return 0, 0, false
256+ }
257+ suffix := text[start:trimmedEnd]
258+ separator := strings.LastIndex(suffix, " ")
259+ if separator < 0 {
260+ return rightBarFallbackPhaseName(text, start, trimmedEnd)
261+ }
262+ nameEnd := start + separator
263+ progress := strings.TrimSpace(text[nameEnd:trimmedEnd])
264+ if !rightBarProgressToken(progress) {
265+ return rightBarFallbackPhaseName(text, start, trimmedEnd)
266+ }
267+ for nameEnd > start && text[nameEnd-1] == ' ' {
268+ nameEnd--
269+ }
270+ if nameEnd <= start {
271+ return 0, 0, false
272+ }
273+ return start, nameEnd, true
274+}
275+
276+func rightBarFallbackPhaseName(text string, start, end int) (int, int, bool) {
277+ for end > start && text[end-1] == ' ' {
278+ end--
279+ }
280+ if end-start >= 3 && text[end-3:end] == "..." {
281+ end -= 3
282+ }
283+ for end > start && text[end-1] == ' ' {
284+ end--
285+ }
286+ if end <= start {
287+ return 0, 0, false
288+ }
289+ return start, end, true
290+}
291+
292+func rightBarProgressToken(value string) bool {
293+ separator := strings.IndexByte(value, '/')
294+ if separator <= 0 || separator == len(value)-1 {
295+ return false
296+ }
297+ for _, part := range []string{value[:separator], value[separator+1:]} {
298+ for _, r := range part {
299+ if r < '0' || r > '9' {
300+ return false
301+ }
146 }302 }
147 }303 }
304+ return true
305+}
306+
307+func rightBarActivePhaseIndent(lines []string) int {
308+ for _, line := range lines {
309+ start, _, marker, ok := rightBarChecklistMarker(line)
310+ if ok && marker == '>' {
311+ return start
312+ }
313+ }
314+ return -1
315+}
148 316
149- border := func(glyph string) string { return th.FG256(th.Muted, glyph) }317+func rightBarChecklistLineDim(text string, activePhaseIndent int, activePhase *bool) bool {
150- out := make([]string, 0, height)318+ if activePhaseIndent < 0 {
151- out = append(out, border("┌")+strings.Repeat("─", innerWidth)+border("┐"))319+ return false
152- for _, line := range content {320+ }
153- out = append(out, border("│")+line+border("│"))321+ start, _, marker, ok := rightBarChecklistMarker(text)
322+ if !ok {
323+ return false
324+ }
325+ if start == activePhaseIndent {
326+ *activePhase = marker == '>'
327+ return !*activePhase
154 }328 }
155- for len(out) < height-1 {329+ if start > activePhaseIndent {
156- out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│"))330+ return !*activePhase
157 }331 }
158- out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘"))332+ return false
159- return out
160 }333 }
161 334
162 // JoinRightBar combines one main-pane row and one right-bar row without335 // JoinRightBar combines one main-pane row and one right-bar row without
@@ -228,12 +401,14 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow
228 401
229 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a402 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
230 // later fallback to DrawLog starts with a full repaint instead of trying403 // later fallback to DrawLog starts with a full repaint instead of trying
231- // to diff against logical rows from before the rail was enabled.404+ // to diff against logical rows from before the rail was enabled. Mark the
405+ // transition separately so that repaint does not clear scrollback.
232 r.logChat = nil406 r.logChat = nil
233 r.logBottom = nil407 r.logBottom = nil
234 r.logLines = nil408 r.logLines = nil
235 r.logViewportTop = 0409 r.logViewportTop = 0
236 r.logHardwareRow = 0410 r.logHardwareRow = 0
237 r.logInit = false411 r.logInit = false
412+ r.logNeedsFullRepaint = true
238 r.Draw(frame, cursorRow, cursorCol)413 r.Draw(frame, cursorRow, cursorCol)
239 }414 }
M packages/tui/rightbar_test.go +157 -3
@@ -31,23 +31,152 @@ func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
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 TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) {
55+ lines := RenderRightBar(Dark, []RightBarWidget{{
56+ Extension: "plan",
57+ Lines: []string{"one two three four five six"},
58+ }}, 16, 8)
59+
60+ if len(lines) != 8 {
61+ t.Fatalf("right bar rows = %d, want 8", len(lines))
62+ }
63+ plain := stripANSI(strings.Join(lines, "\n"))
64+ if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") {
65+ t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain)
66+ }
67+ for idx, line := range lines {
68+ if width := visibleWidth(line); width != 16 {
69+ t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line)
70+ }
71+ }
72+}
73+
74+func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
75+ lines := RenderRightBar(Dark, []RightBarWidget{
76+ {
77+ Extension: "tasked-phases",
78+ Lines: []string{"[ ] A phase title that is much too long to fit 0/3"},
79+ },
80+ }, 24, 5)
81+ plainLines := strings.Split(stripANSI(strings.Join(lines, "\n")), "\n")
82+ if len(plainLines) < 3 {
83+ t.Fatalf("phase row is missing: %q", plainLines)
84+ }
85+ phase := strings.TrimRight(plainLines[2], " ")
86+ if !strings.HasSuffix(phase, "0/3") || !strings.Contains(phase, "... 0/3") {
87+ t.Fatalf("phase progress was clipped with its title: %q", phase)
88+ }
89+}
90+
91+func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
92+ lines := RenderRightBar(Dark, []RightBarWidget{{
93+ Extension: "tasked-phases",
94+ Title: "p 0/1 | t 0/1",
95+ Lines: []string{
96+ "[>] Copper Lantern Setup 0/1",
97+ " [ ] Sort the leftover star stickers",
98+ },
99+ }}, 36, 8)
100+ plain := stripANSI(strings.Join(lines, "\n"))
101+ if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") {
102+ t.Fatalf("phase row has the wrong indentation: %q", plain)
103+ }
104+ plainLines := strings.Split(plain, "\n")
105+ if len(plainLines) < 4 {
106+ t.Fatalf("checklist rows are missing: %q", plain)
107+ }
108+ expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)
109+ if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
110+ t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
111+ }
112+ if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
113+ t.Fatalf("phase name is not bold: %q", lines)
114+ }
115+ if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
116+ t.Fatalf("task text should not be bold: %q", lines)
117+ }
118+ if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
119+ t.Fatalf("widget title is not followed by an empty row: %q", plain)
120+ }
121+}
122+
123+func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
124+ lines := RenderRightBar(Dark, []RightBarWidget{{
125+ Extension: "plan",
126+ Lines: []string{"[x] done", "[>] active", "[ ] pending"},
127+ }}, 24, 8)
128+ rendered := strings.Join(lines, "\n")
129+ for _, marker := range []string{"[x]", "[>]", "[ ]"} {
130+ if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) &&
131+ !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) {
132+ t.Fatalf("marker %q was not muted: %q", marker, rendered)
133+ }
134+ }
135+ if !strings.Contains(stripANSI(rendered), " done") {
136+ t.Fatalf("checklist text was not kept readable: %q", rendered)
137+ }
138+}
139+
140+func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
141+ lines := RenderRightBar(Dark, []RightBarWidget{{
142+ Extension: "plan",
143+ Lines: []string{
144+ "[x] finished phase",
145+ " [x] finished task",
146+ "[>] active phase",
147+ " [>] active task",
148+ "[ ] pending phase",
149+ " [ ] pending task",
150+ },
151+ }}, 32, 12)
152+ for _, line := range lines {
153+ if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") {
154+ if strings.Contains(line, "\x1b[2m") {
155+ t.Fatalf("active checklist row was dimmed: %q", line)
156+ }
157+ }
158+ }
159+ plain := stripANSI(strings.Join(lines, "\n"))
160+ for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} {
161+ if !strings.Contains(plain, text) {
162+ t.Fatalf("inactive checklist text %q missing: %q", text, plain)
163+ }
164+ }
165+ if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") {
166+ t.Fatalf("inactive checklist rows were not dimmed: %q", lines)
167+ }
168+}
169+
170+func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
171+ line := JoinRightBar(Dark, "main", "right", 8, 8)
172+ if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) {
173+ t.Fatalf("separator is not muted: %q", line)
174+ }
175+ if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
176+ t.Fatalf("joined right bar added an outer rule: %q", line)
177+ }
178+}
179+
51180 func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
52181 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
53182 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {
@@ -55,6 +184,31 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
55184 }
56185 }
57186
187+func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
188+ t.Setenv("TERM_PROGRAM", "")
189+
190+ var out bytes.Buffer
191+ r := NewRenderer(&out)
192+ r.SetTheme(Dark)
193+ r.Resize(80, 5)
194+ r.DrawRightBar(
195+ []string{"chat"},
196+ []string{"input"},
197+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
198+ 0,
199+ 0,
200+ )
201+
202+ out.Reset()
203+ r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
204+ if !strings.Contains(stripANSI(out.String()), "input") {
205+ t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String())
206+ }
207+ if strings.Contains(out.String(), SeqClearScrollback) {
208+ t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
209+ }
210+}
211+
58212 func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
59213 var out bytes.Buffer
60214 r := NewRenderer(&out)
@@ -31,23 +31,152 @@ func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
31 t.Fatalf("right bar rows = %d, want 7", len(lines))31 t.Fatalf("right bar rows = %d, want 7", len(lines))
32 }32 }
33 for idx, line := range lines {33 for idx, line := range lines {
34- if width := visibleWidth(line); width > 24 {34+ if width := visibleWidth(line); width != 24 {
35- t.Fatalf("right bar line %d width = %d, want <= 24: %q", idx, width, line)35+ t.Fatalf("right bar line %d width = %d, want 24: %q", idx, width, line)
36 }36 }
37 }37 }
38 joined := stripANSI(strings.Join(lines, "\n"))38 joined := stripANSI(strings.Join(lines, "\n"))
39 if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {39 if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {
40 t.Fatalf("widgets are not deterministic by extension: %q", joined)40 t.Fatalf("widgets are not deterministic by extension: %q", joined)
41 }41 }
42+ if strings.ContainsAny(joined, "┌┐└┘─") {
43+ t.Fatalf("right bar added an outer border: %q", joined)
44+ }
42 45
43 truncated := RenderRightBar(Dark, []RightBarWidget{46 truncated := RenderRightBar(Dark, []RightBarWidget{
44- {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f"}},47+ {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f", "g", "h"}},
45 }, 24, 7)48 }, 24, 7)
46 if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {49 if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {
47 t.Fatalf("truncation marker missing: %q", truncated)50 t.Fatalf("truncation marker missing: %q", truncated)
48 }51 }
49 }52 }
50 53
54+func TestRenderRightBarClipsLongWidgetLinesWithEllipsis(t *testing.T) {
55+ lines := RenderRightBar(Dark, []RightBarWidget{{
56+ Extension: "plan",
57+ Lines: []string{"one two three four five six"},
58+ }}, 16, 8)
59+
60+ if len(lines) != 8 {
61+ t.Fatalf("right bar rows = %d, want 8", len(lines))
62+ }
63+ plain := stripANSI(strings.Join(lines, "\n"))
64+ if !strings.Contains(plain, " one two thre...") || strings.Contains(plain, "four five six") {
65+ t.Fatalf("long widget line was not clipped with an ellipsis: %q", plain)
66+ }
67+ for idx, line := range lines {
68+ if width := visibleWidth(line); width != 16 {
69+ t.Fatalf("clipped right bar line %d width = %d, want 16: %q", idx, width, line)
70+ }
71+ }
72+}
73+
74+func TestRenderRightBarPreservesPhaseProgressWhenTitleIsClipped(t *testing.T) {
75+ lines := RenderRightBar(Dark, []RightBarWidget{
76+ {
77+ Extension: "tasked-phases",
78+ Lines: []string{"[ ] A phase title that is much too long to fit 0/3"},
79+ },
80+ }, 24, 5)
81+ plainLines := strings.Split(stripANSI(strings.Join(lines, "\n")), "\n")
82+ if len(plainLines) < 3 {
83+ t.Fatalf("phase row is missing: %q", plainLines)
84+ }
85+ phase := strings.TrimRight(plainLines[2], " ")
86+ if !strings.HasSuffix(phase, "0/3") || !strings.Contains(phase, "... 0/3") {
87+ t.Fatalf("phase progress was clipped with its title: %q", phase)
88+ }
89+}
90+
91+func TestRenderRightBarUsesChecklistIndentEllipsisAndBoldPhases(t *testing.T) {
92+ lines := RenderRightBar(Dark, []RightBarWidget{{
93+ Extension: "tasked-phases",
94+ Title: "p 0/1 | t 0/1",
95+ Lines: []string{
96+ "[>] Copper Lantern Setup 0/1",
97+ " [ ] Sort the leftover star stickers",
98+ },
99+ }}, 36, 8)
100+ plain := stripANSI(strings.Join(lines, "\n"))
101+ if !strings.Contains(plain, " [>] Copper Lantern Setup 0/1") {
102+ t.Fatalf("phase row has the wrong indentation: %q", plain)
103+ }
104+ plainLines := strings.Split(plain, "\n")
105+ if len(plainLines) < 4 {
106+ t.Fatalf("checklist rows are missing: %q", plain)
107+ }
108+ expectedTask := truncateRightBarLine(" [ ] Sort the leftover star stickers", 36)
109+ if got := strings.TrimRight(plainLines[3], " "); got != expectedTask {
110+ t.Fatalf("task row = %q, want clipped row %q", got, expectedTask)
111+ }
112+ if !strings.Contains(strings.Join(lines, "\n"), Bold("Copper Lantern Setup")) {
113+ t.Fatalf("phase name is not bold: %q", lines)
114+ }
115+ if strings.Contains(strings.Join(lines, "\n"), Bold("Sort the leftover")) {
116+ t.Fatalf("task text should not be bold: %q", lines)
117+ }
118+ if len(plainLines) < 2 || !strings.HasPrefix(plainLines[0], "[tasked-phases] p 0/1 | t 0/1") || strings.TrimSpace(plainLines[1]) != "" {
119+ t.Fatalf("widget title is not followed by an empty row: %q", plain)
120+ }
121+}
122+
123+func TestRenderRightBarDimsChecklistMarkers(t *testing.T) {
124+ lines := RenderRightBar(Dark, []RightBarWidget{{
125+ Extension: "plan",
126+ Lines: []string{"[x] done", "[>] active", "[ ] pending"},
127+ }}, 24, 8)
128+ rendered := strings.Join(lines, "\n")
129+ for _, marker := range []string{"[x]", "[>]", "[ ]"} {
130+ if !strings.Contains(rendered, Dark.FG256(Dark.Muted, marker)) &&
131+ !strings.Contains(rendered, Dark.FG256(Dark.Muted, Dim(marker))) {
132+ t.Fatalf("marker %q was not muted: %q", marker, rendered)
133+ }
134+ }
135+ if !strings.Contains(stripANSI(rendered), " done") {
136+ t.Fatalf("checklist text was not kept readable: %q", rendered)
137+ }
138+}
139+
140+func TestRenderRightBarDimsInactiveChecklistSections(t *testing.T) {
141+ lines := RenderRightBar(Dark, []RightBarWidget{{
142+ Extension: "plan",
143+ Lines: []string{
144+ "[x] finished phase",
145+ " [x] finished task",
146+ "[>] active phase",
147+ " [>] active task",
148+ "[ ] pending phase",
149+ " [ ] pending task",
150+ },
151+ }}, 32, 12)
152+ for _, line := range lines {
153+ if strings.Contains(stripANSI(line), "active phase") || strings.Contains(stripANSI(line), "active task") {
154+ if strings.Contains(line, "\x1b[2m") {
155+ t.Fatalf("active checklist row was dimmed: %q", line)
156+ }
157+ }
158+ }
159+ plain := stripANSI(strings.Join(lines, "\n"))
160+ for _, text := range []string{"finished phase", "finished task", "pending phase", "pending task"} {
161+ if !strings.Contains(plain, text) {
162+ t.Fatalf("inactive checklist text %q missing: %q", text, plain)
163+ }
164+ }
165+ if !strings.Contains(strings.Join(lines, "\n"), "\x1b[2m") {
166+ t.Fatalf("inactive checklist rows were not dimmed: %q", lines)
167+ }
168+}
169+
170+func TestJoinRightBarUsesOnlyMutedSeparator(t *testing.T) {
171+ line := JoinRightBar(Dark, "main", "right", 8, 8)
172+ if !strings.Contains(line, Dark.FG256(Dark.Muted, "│")) {
173+ t.Fatalf("separator is not muted: %q", line)
174+ }
175+ if strings.ContainsAny(stripANSI(line), "┌┐└┘─") {
176+ t.Fatalf("joined right bar added an outer rule: %q", line)
177+ }
178+}
179+
51 func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {180 func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
52 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)181 line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
53 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {182 if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {
@@ -55,6 +184,31 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
55 }184 }
56 }185 }
57 186
187+func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
188+ t.Setenv("TERM_PROGRAM", "")
189+
190+ var out bytes.Buffer
191+ r := NewRenderer(&out)
192+ r.SetTheme(Dark)
193+ r.Resize(80, 5)
194+ r.DrawRightBar(
195+ []string{"chat"},
196+ []string{"input"},
197+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
198+ 0,
199+ 0,
200+ )
201+
202+ out.Reset()
203+ r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
204+ if !strings.Contains(stripANSI(out.String()), "input") {
205+ t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String())
206+ }
207+ if strings.Contains(out.String(), SeqClearScrollback) {
208+ t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
209+ }
210+}
211+
58 func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {212 func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
59 var out bytes.Buffer213 var out bytes.Buffer
60 r := NewRenderer(&out)214 r := NewRenderer(&out)

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