detached 44c56dd

feat(agent): add extension right-bar widgets

commit
44c56dd8aaa438c8e6d4dbf772ff6862d663bdb1
author
brice · · 2026-08-05 08:56 UTC
parent
1513f55
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +743 -65
M docs/extensions.md +25 -3
@@ -418,11 +418,22 @@ Sets or replaces one status item owned by the extension. Sending an empty
418418
419419 #### `widget` (one-way, persistent)
420420
421-Sets or replaces a compact widget. Interactive zot currently renders widgets
422-above the input; other hosts may ignore the placement hint.
421+Sets or replaces a compact widget. `position` is a host-defined placement
422+hint with these interactive-zot values:
423+
424+- `"above_input"` keeps the widget in the existing persistent chrome above
425+ the editor.
426+- `"right_bar"` keeps the widget in a display-only side rail beside the
427+ transcript when the terminal is wide enough.
428+
429+The right bar is host-owned: it bounds width and height, orders widgets by
430+extension and ID, and truncates content that does not fit. On narrow terminals
431+it falls back automatically to `above_input`. Empty or unknown positions retain
432+the historical `above_input` behavior so older extensions remain compatible.
433+Use `open_panel` when a widget needs interaction or navigation.
423434
424435 ```json
425-{"type":"widget","id":"plan","position":"above_input",
436+{"type":"widget","id":"plan","position":"right_bar",
426437 "title":"Tasked phases","lines":["Current phase: parse","[ ] validate inputs"]}
427438 ```
428439
@@ -704,6 +715,17 @@ latest persisted snapshot. A tool can return opaque state with
704715 request. `SetStatus`, `SetWidget`, and their clear methods update persistent
705716 interactive chrome without entering the transcript.
706717
718+For Go extensions, use the SDK constants so the host can choose the responsive
719+layout without extension-specific terminal code:
720+
721+```go
722+e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines)
723+e.ClearWidget("plan")
724+```
725+
726+`right_bar` widgets are display-only in the first version. Open an extension
727+panel for editing or keyboard navigation.
728+
707729 The SDK has four interceptor hooks, all optional:
708730
709731 ```go
@@ -418,11 +418,22 @@ Sets or replaces one status item owned by the extension. Sending an empty
418 418
419 #### `widget` (one-way, persistent)419 #### `widget` (one-way, persistent)
420 420
421-Sets or replaces a compact widget. Interactive zot currently renders widgets421+Sets or replaces a compact widget. `position` is a host-defined placement
422-above the input; other hosts may ignore the placement hint.422+hint with these interactive-zot values:
423+
424+- `"above_input"` keeps the widget in the existing persistent chrome above
425+ the editor.
426+- `"right_bar"` keeps the widget in a display-only side rail beside the
427+ transcript when the terminal is wide enough.
428+
429+The right bar is host-owned: it bounds width and height, orders widgets by
430+extension and ID, and truncates content that does not fit. On narrow terminals
431+it falls back automatically to `above_input`. Empty or unknown positions retain
432+the historical `above_input` behavior so older extensions remain compatible.
433+Use `open_panel` when a widget needs interaction or navigation.
423 434
424 ```json435 ```json
425-{"type":"widget","id":"plan","position":"above_input",436+{"type":"widget","id":"plan","position":"right_bar",
426 "title":"Tasked phases","lines":["Current phase: parse","[ ] validate inputs"]}437 "title":"Tasked phases","lines":["Current phase: parse","[ ] validate inputs"]}
427 ```438 ```
428 439
@@ -704,6 +715,17 @@ latest persisted snapshot. A tool can return opaque state with
704 request. `SetStatus`, `SetWidget`, and their clear methods update persistent715 request. `SetStatus`, `SetWidget`, and their clear methods update persistent
705 interactive chrome without entering the transcript.716 interactive chrome without entering the transcript.
706 717
718+For Go extensions, use the SDK constants so the host can choose the responsive
719+layout without extension-specific terminal code:
720+
721+```go
722+e.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", lines)
723+e.ClearWidget("plan")
724+```
725+
726+`right_bar` widgets are display-only in the first version. Open an extension
727+panel for editing or keyboard navigation.
728+
707 The SDK has four interceptor hooks, all optional:729 The SDK has four interceptor hooks, all optional:
708 730
709 ```go731 ```go
M docs/rpc.md +3 -0
@@ -205,6 +205,9 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
205205 | `error` | `message` | Non-fatal error message |
206206 | `compact_done` | `summary` | Compaction finished, summary text included |
207207 | `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. |
208+| `ext_widget` | `extension`, `id`, `position`, `title`, `lines` | Persistent extension widget update. `position` may be `above_input` or `right_bar`; clients choose their own layout. |
209+| `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. |
210+| `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. |
208211
209212 ## Message shape
210213
@@ -205,6 +205,9 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
205 | `error` | `message` | Non-fatal error message |205 | `error` | `message` | Non-fatal error message |
206 | `compact_done` | `summary` | Compaction finished, summary text included |206 | `compact_done` | `summary` | Compaction finished, summary text included |
207 | `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. |207 | `ext_alert` | `extension`, `kind`, optional `reason` | An extension requested a host alert; RPC clients can map it to their own notification UI. |
208+| `ext_widget` | `extension`, `id`, `position`, `title`, `lines` | Persistent extension widget update. `position` may be `above_input` or `right_bar`; clients choose their own layout. |
209+| `ext_widget_clear` | `extension`, `id` | Remove one persistent extension widget. |
210+| `ext_chrome_clear` | `extension` | The extension exited; discard all persistent status/widget chrome owned by it. |
208 211
209 ## Message shape212 ## Message shape
210 213
M examples/extensions/tasked-phases/README.md +5 -0
@@ -62,3 +62,8 @@ reminds the model to update the checklist continuously, mark each task as soon
6262 as it is completed, and advance the current phase when work moves forward.
6363 Completed phases and future phases are not repeated in every provider request;
6464 use `get_status` or `/phases` when the full checklist is needed.
65+
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.
@@ -62,3 +62,8 @@ reminds the model to update the checklist continuously, mark each task as soon
62 as it is completed, and advance the current phase when work moves forward.62 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;63 Completed phases and future phases are not repeated in every provider request;
64 use `get_status` or `/phases` when the full checklist is needed.64 use `get_status` or `/phases` when the full checklist is needed.
65+
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.
M examples/extensions/tasked-phases/main.go +2 -2
@@ -202,7 +202,7 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
202202
203203 func (a *app) publishUnavailableChrome() {
204204 a.ext.SetStatus("progress", "plan unavailable")
205- a.ext.SetWidget("plan", "above_input", "Tasked phases", []string{"Plan state could not be restored for this session."})
205+ a.ext.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", []string{"Plan state could not be restored for this session."})
206206 a.panelMu.Lock()
207207 open := a.panelOpen
208208 a.panelMu.Unlock()
@@ -251,7 +251,7 @@ func applyChrome(host chromeHost, state PlanState) {
251251 return
252252 }
253253 host.SetStatus("progress", chrome.status)
254- host.SetWidget("plan", "above_input", "Tasked phases", chrome.lines)
254+ host.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", chrome.lines)
255255 }
256256
257257 func (a *app) publishChrome(state PlanState) {
@@ -202,7 +202,7 @@ func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
202 202
203 func (a *app) publishUnavailableChrome() {203 func (a *app) publishUnavailableChrome() {
204 a.ext.SetStatus("progress", "plan unavailable")204 a.ext.SetStatus("progress", "plan unavailable")
205- a.ext.SetWidget("plan", "above_input", "Tasked phases", []string{"Plan state could not be restored for this session."})205+ a.ext.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", []string{"Plan state could not be restored for this session."})
206 a.panelMu.Lock()206 a.panelMu.Lock()
207 open := a.panelOpen207 open := a.panelOpen
208 a.panelMu.Unlock()208 a.panelMu.Unlock()
@@ -251,7 +251,7 @@ func applyChrome(host chromeHost, state PlanState) {
251 return251 return
252 }252 }
253 host.SetStatus("progress", chrome.status)253 host.SetStatus("progress", chrome.status)
254- host.SetWidget("plan", "above_input", "Tasked phases", chrome.lines)254+ host.SetWidget("plan", ext.WidgetPositionRightBar, "Tasked phases", chrome.lines)
255 }255 }
256 256
257 func (a *app) publishChrome(state PlanState) {257 func (a *app) publishChrome(state PlanState) {
M examples/extensions/tasked-phases/main_test.go +26 -5
@@ -3,6 +3,8 @@ package main
33 import (
44 "strings"
55 "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/ext"
68 )
79
810 func TestBuildChromeHidesEmptyPlan(t *testing.T) {
@@ -42,19 +44,38 @@ func TestBuildChromeShowsStoredPlan(t *testing.T) {
4244 }
4345
4446 type chromeRecorder struct {
45- statusSet int
46- statusClear int
47- widgetSet int
48- widgetClear int
47+ statusSet int
48+ statusClear int
49+ widgetSet int
50+ widgetClear int
51+ widgetPositions []string
4952 }
5053
5154 func (r *chromeRecorder) SetStatus(string, string) { r.statusSet++ }
5255 func (r *chromeRecorder) ClearStatus(string) { r.statusClear++ }
53-func (r *chromeRecorder) SetWidget(string, string, string, []string) {
56+func (r *chromeRecorder) SetWidget(_, position, _ string, _ []string) {
5457 r.widgetSet++
58+ r.widgetPositions = append(r.widgetPositions, position)
5559 }
5660 func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ }
5761
62+func TestApplyChromeUsesRightBar(t *testing.T) {
63+ recorder := &chromeRecorder{}
64+ applyChrome(recorder, PlanState{
65+ 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+ })
73+
74+ 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)
76+ }
77+}
78+
5879 func TestApplyChromeClearsEmptyPlan(t *testing.T) {
5980 recorder := &chromeRecorder{}
6081 applyChrome(recorder, createEmptyState())
@@ -3,6 +3,8 @@ package main
3 import (3 import (
4 "strings"4 "strings"
5 "testing"5 "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/ext"
6 )8 )
7 9
8 func TestBuildChromeHidesEmptyPlan(t *testing.T) {10 func TestBuildChromeHidesEmptyPlan(t *testing.T) {
@@ -42,19 +44,38 @@ func TestBuildChromeShowsStoredPlan(t *testing.T) {
42 }44 }
43 45
44 type chromeRecorder struct {46 type chromeRecorder struct {
45- statusSet int47+ statusSet int
46- statusClear int48+ statusClear int
47- widgetSet int49+ widgetSet int
48- widgetClear int50+ widgetClear int
51+ widgetPositions []string
49 }52 }
50 53
51 func (r *chromeRecorder) SetStatus(string, string) { r.statusSet++ }54 func (r *chromeRecorder) SetStatus(string, string) { r.statusSet++ }
52 func (r *chromeRecorder) ClearStatus(string) { r.statusClear++ }55 func (r *chromeRecorder) ClearStatus(string) { r.statusClear++ }
53-func (r *chromeRecorder) SetWidget(string, string, string, []string) {56+func (r *chromeRecorder) SetWidget(_, position, _ string, _ []string) {
54 r.widgetSet++57 r.widgetSet++
58+ r.widgetPositions = append(r.widgetPositions, position)
55 }59 }
56 func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ }60 func (r *chromeRecorder) ClearWidget(string) { r.widgetClear++ }
57 61
62+func TestApplyChromeUsesRightBar(t *testing.T) {
63+ recorder := &chromeRecorder{}
64+ applyChrome(recorder, PlanState{
65+ 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+ })
73+
74+ 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)
76+ }
77+}
78+
58 func TestApplyChromeClearsEmptyPlan(t *testing.T) {79 func TestApplyChromeClearsEmptyPlan(t *testing.T) {
59 recorder := &chromeRecorder{}80 recorder := &chromeRecorder{}
60 applyChrome(recorder, createEmptyState())81 applyChrome(recorder, createEmptyState())
M packages/agent/cli.go +25 -0
@@ -217,6 +217,31 @@ func (h *interactiveExtHooks) ClearWidget(extName, id string) {
217217 iv.ClearWidget(extName, id)
218218 }
219219
220+func (h *interactiveExtHooks) ClearExtensionChrome(extName string) {
221+ if h == nil {
222+ return
223+ }
224+ h.mu.Lock()
225+ if h.interactive == nil {
226+ prefix := extName + "\x00"
227+ for key := range h.pendingStatuses {
228+ if strings.HasPrefix(key, prefix) {
229+ delete(h.pendingStatuses, key)
230+ }
231+ }
232+ for key := range h.pendingWidgets {
233+ if strings.HasPrefix(key, prefix) {
234+ delete(h.pendingWidgets, key)
235+ }
236+ }
237+ h.mu.Unlock()
238+ return
239+ }
240+ iv := h.interactive
241+ h.mu.Unlock()
242+ iv.ClearExtensionChrome(extName)
243+}
244+
220245 // extToolAdapter bridges *extensions.Manager to the
221246 // ExtensionToolSource interface declared in build.go (kept narrow to
222247 // avoid a build->extensions import cycle). One adapter instance per
@@ -217,6 +217,31 @@ func (h *interactiveExtHooks) ClearWidget(extName, id string) {
217 iv.ClearWidget(extName, id)217 iv.ClearWidget(extName, id)
218 }218 }
219 219
220+func (h *interactiveExtHooks) ClearExtensionChrome(extName string) {
221+ if h == nil {
222+ return
223+ }
224+ h.mu.Lock()
225+ if h.interactive == nil {
226+ prefix := extName + "\x00"
227+ for key := range h.pendingStatuses {
228+ if strings.HasPrefix(key, prefix) {
229+ delete(h.pendingStatuses, key)
230+ }
231+ }
232+ for key := range h.pendingWidgets {
233+ if strings.HasPrefix(key, prefix) {
234+ delete(h.pendingWidgets, key)
235+ }
236+ }
237+ h.mu.Unlock()
238+ return
239+ }
240+ iv := h.interactive
241+ h.mu.Unlock()
242+ iv.ClearExtensionChrome(extName)
243+}
244+
220 // extToolAdapter bridges *extensions.Manager to the245 // extToolAdapter bridges *extensions.Manager to the
221 // ExtensionToolSource interface declared in build.go (kept narrow to246 // ExtensionToolSource interface declared in build.go (kept narrow to
222 // avoid a build->extensions import cycle). One adapter instance per247 // avoid a build->extensions import cycle). One adapter instance per
M packages/agent/ext/ext.go +11 -1
@@ -520,7 +520,17 @@ func (e *Extension) SetStatus(key, text string) {
520520 // ClearStatus removes one persistent status item owned by this extension.
521521 func (e *Extension) ClearStatus(key string) { e.SetStatus(key, "") }
522522
523-// SetWidget replaces one persistent widget owned by this extension.
523+const (
524+ // WidgetPositionAboveInput keeps a widget in the persistent chrome above
525+ // the editor. It is the default for empty and unknown positions.
526+ WidgetPositionAboveInput = extproto.WidgetPositionAboveInput
527+ // WidgetPositionRightBar keeps a widget in the host-owned side rail when
528+ // the terminal is wide enough; narrow terminals fall back above the input.
529+ WidgetPositionRightBar = extproto.WidgetPositionRightBar
530+)
531+
532+// SetWidget replaces one persistent widget owned by this extension. Use
533+// WidgetPositionAboveInput or WidgetPositionRightBar for the position.
524534 func (e *Extension) SetWidget(id, position, title string, lines []string) {
525535 _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines})
526536 }
@@ -520,7 +520,17 @@ func (e *Extension) SetStatus(key, text string) {
520 // ClearStatus removes one persistent status item owned by this extension.520 // ClearStatus removes one persistent status item owned by this extension.
521 func (e *Extension) ClearStatus(key string) { e.SetStatus(key, "") }521 func (e *Extension) ClearStatus(key string) { e.SetStatus(key, "") }
522 522
523-// SetWidget replaces one persistent widget owned by this extension.523+const (
524+ // WidgetPositionAboveInput keeps a widget in the persistent chrome above
525+ // the editor. It is the default for empty and unknown positions.
526+ WidgetPositionAboveInput = extproto.WidgetPositionAboveInput
527+ // WidgetPositionRightBar keeps a widget in the host-owned side rail when
528+ // the terminal is wide enough; narrow terminals fall back above the input.
529+ WidgetPositionRightBar = extproto.WidgetPositionRightBar
530+)
531+
532+// SetWidget replaces one persistent widget owned by this extension. Use
533+// WidgetPositionAboveInput or WidgetPositionRightBar for the position.
524 func (e *Extension) SetWidget(id, position, title string, lines []string) {534 func (e *Extension) SetWidget(id, position, title string, lines []string) {
525 _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines})535 _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines})
526 }536 }
M packages/agent/ext/ext_test.go +2 -2
@@ -255,13 +255,13 @@ func TestPersistentChromeFrames(t *testing.T) {
255255 t.Fatalf("status = %+v", sf)
256256 }
257257
258- h.ext.SetWidget("plan", "above_input", "Plan", []string{"one"})
258+ h.ext.SetWidget("plan", WidgetPositionRightBar, "Plan", []string{"one"})
259259 widget := h.drainUntil(t, "widget")
260260 var wf extproto.WidgetFromExt
261261 if err := json.Unmarshal(widget.raw, &wf); err != nil {
262262 t.Fatal(err)
263263 }
264- if wf.ID != "plan" || wf.Position != "above_input" || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {
264+ if wf.ID != "plan" || wf.Position != WidgetPositionRightBar || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {
265265 t.Fatalf("widget = %+v", wf)
266266 }
267267
@@ -255,13 +255,13 @@ func TestPersistentChromeFrames(t *testing.T) {
255 t.Fatalf("status = %+v", sf)255 t.Fatalf("status = %+v", sf)
256 }256 }
257 257
258- h.ext.SetWidget("plan", "above_input", "Plan", []string{"one"})258+ h.ext.SetWidget("plan", WidgetPositionRightBar, "Plan", []string{"one"})
259 widget := h.drainUntil(t, "widget")259 widget := h.drainUntil(t, "widget")
260 var wf extproto.WidgetFromExt260 var wf extproto.WidgetFromExt
261 if err := json.Unmarshal(widget.raw, &wf); err != nil {261 if err := json.Unmarshal(widget.raw, &wf); err != nil {
262 t.Fatal(err)262 t.Fatal(err)
263 }263 }
264- if wf.ID != "plan" || wf.Position != "above_input" || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {264+ if wf.ID != "plan" || wf.Position != WidgetPositionRightBar || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {
265 t.Fatalf("widget = %+v", wf)265 t.Fatalf("widget = %+v", wf)
266 }266 }
267 267
M packages/agent/extensions/intercept_test.go +8 -1
@@ -175,7 +175,8 @@ done
175175 t.Fatal(err)
176176 }
177177
178- m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", nil)
178+ hooks := &stubHooks{}
179+ m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", hooks)
179180 t.Cleanup(func() { m.Stop(2 * time.Second) })
180181
181182 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -202,6 +203,12 @@ done
202203 if !reloadFired {
203204 t.Error("onReload callback didn't fire")
204205 }
206+ hooks.mu.Lock()
207+ clears := append([]string(nil), hooks.chromeClears...)
208+ hooks.mu.Unlock()
209+ if len(clears) == 0 || clears[0] != "rtest" {
210+ t.Fatalf("extension chrome cleanup callbacks = %v", clears)
211+ }
205212
206213 // Registered command should still be there after reload.
207214 if !m.HasCommand("rtest") {
@@ -175,7 +175,8 @@ done
175 t.Fatal(err)175 t.Fatal(err)
176 }176 }
177 177
178- m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", nil)178+ hooks := &stubHooks{}
179+ m := New(t.TempDir(), "", "0.0.0-test", "anthropic", "claude-test", hooks)
179 t.Cleanup(func() { m.Stop(2 * time.Second) })180 t.Cleanup(func() { m.Stop(2 * time.Second) })
180 181
181 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)182 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -202,6 +203,12 @@ done
202 if !reloadFired {203 if !reloadFired {
203 t.Error("onReload callback didn't fire")204 t.Error("onReload callback didn't fire")
204 }205 }
206+ hooks.mu.Lock()
207+ clears := append([]string(nil), hooks.chromeClears...)
208+ hooks.mu.Unlock()
209+ if len(clears) == 0 || clears[0] != "rtest" {
210+ t.Fatalf("extension chrome cleanup callbacks = %v", clears)
211+ }
205 212
206 // Registered command should still be there after reload.213 // Registered command should still be there after reload.
207 if !m.HasCommand("rtest") {214 if !m.HasCommand("rtest") {
M packages/agent/extensions/manager.go +10 -0
@@ -199,6 +199,13 @@ type AlertHostHooks interface {
199199 Alert(extName string, alert extproto.AlertRequest)
200200 }
201201
202+// ExtensionChromeHooks is an optional lifecycle hook for hosts that retain
203+// persistent extension-owned UI. The manager calls it when an extension exits
204+// so a crash, disable, or reload cannot leave stale widgets or statuses.
205+type ExtensionChromeHooks interface {
206+ ClearExtensionChrome(extName string)
207+}
208+
202209 type commandRegistration struct {
203210 ext *Extension
204211 name string
@@ -821,6 +828,9 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
821828 }
822829 }
823830 m.mu.Unlock()
831+ if hooks, ok := m.hooks.(ExtensionChromeHooks); ok {
832+ hooks.ClearExtensionChrome(ext.Manifest.Name)
833+ }
824834 ext.stopLifecycleWriter()
825835 ext.readyOnce.Do(func() { close(ext.readyCh) })
826836 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
@@ -199,6 +199,13 @@ type AlertHostHooks interface {
199 Alert(extName string, alert extproto.AlertRequest)199 Alert(extName string, alert extproto.AlertRequest)
200 }200 }
201 201
202+// ExtensionChromeHooks is an optional lifecycle hook for hosts that retain
203+// persistent extension-owned UI. The manager calls it when an extension exits
204+// so a crash, disable, or reload cannot leave stale widgets or statuses.
205+type ExtensionChromeHooks interface {
206+ ClearExtensionChrome(extName string)
207+}
208+
202 type commandRegistration struct {209 type commandRegistration struct {
203 ext *Extension210 ext *Extension
204 name string211 name string
@@ -821,6 +828,9 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
821 }828 }
822 }829 }
823 m.mu.Unlock()830 m.mu.Unlock()
831+ if hooks, ok := m.hooks.(ExtensionChromeHooks); ok {
832+ hooks.ClearExtensionChrome(ext.Manifest.Name)
833+ }
824 ext.stopLifecycleWriter()834 ext.stopLifecycleWriter()
825 ext.readyOnce.Do(func() { close(ext.readyCh) })835 ext.readyOnce.Do(func() { close(ext.readyCh) })
826 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))836 fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
M packages/agent/extensions/manager_test.go +16 -10
@@ -41,16 +41,17 @@ var _ io.WriteCloser = (*nopWriteCloser)(nil)
4141
4242 // stubHooks records every callback so the test can assert on them.
4343 type stubHooks struct {
44- mu sync.Mutex
45- notifies []string
46- displays []string
47- alerts []extproto.AlertRequest
48- alertExts []string
49- submits []string
50- submitSlash []string
51- clearNotes []string
52- panels []extproto.PanelSpec
53- panelExts []string
44+ mu sync.Mutex
45+ notifies []string
46+ displays []string
47+ alerts []extproto.AlertRequest
48+ alertExts []string
49+ submits []string
50+ submitSlash []string
51+ clearNotes []string
52+ panels []extproto.PanelSpec
53+ panelExts []string
54+ chromeClears []string
5455 }
5556
5657 func (s *stubHooks) Notify(name, level, message string) {
@@ -96,6 +97,11 @@ func (s *stubHooks) ClosePanel(string, string) {}
9697 func (s *stubHooks) SetStatus(string, string, string, string) {}
9798 func (s *stubHooks) SetWidget(string, string, string, string, []string) {}
9899 func (s *stubHooks) ClearWidget(string, string) {}
100+func (s *stubHooks) ClearExtensionChrome(name string) {
101+ s.mu.Lock()
102+ defer s.mu.Unlock()
103+ s.chromeClears = append(s.chromeClears, name)
104+}
99105
100106 // writeMockExtension creates a minimal extension on disk that uses a
101107 // shell script (or batch file on windows) to drive the protocol. The
@@ -41,16 +41,17 @@ var _ io.WriteCloser = (*nopWriteCloser)(nil)
41 41
42 // stubHooks records every callback so the test can assert on them.42 // stubHooks records every callback so the test can assert on them.
43 type stubHooks struct {43 type stubHooks struct {
44- mu sync.Mutex44+ mu sync.Mutex
45- notifies []string45+ notifies []string
46- displays []string46+ displays []string
47- alerts []extproto.AlertRequest47+ alerts []extproto.AlertRequest
48- alertExts []string48+ alertExts []string
49- submits []string49+ submits []string
50- submitSlash []string50+ submitSlash []string
51- clearNotes []string51+ clearNotes []string
52- panels []extproto.PanelSpec52+ panels []extproto.PanelSpec
53- panelExts []string53+ panelExts []string
54+ chromeClears []string
54 }55 }
55 56
56 func (s *stubHooks) Notify(name, level, message string) {57 func (s *stubHooks) Notify(name, level, message string) {
@@ -96,6 +97,11 @@ func (s *stubHooks) ClosePanel(string, string) {}
96 func (s *stubHooks) SetStatus(string, string, string, string) {}97 func (s *stubHooks) SetStatus(string, string, string, string) {}
97 func (s *stubHooks) SetWidget(string, string, string, string, []string) {}98 func (s *stubHooks) SetWidget(string, string, string, string, []string) {}
98 func (s *stubHooks) ClearWidget(string, string) {}99 func (s *stubHooks) ClearWidget(string, string) {}
100+func (s *stubHooks) ClearExtensionChrome(name string) {
101+ s.mu.Lock()
102+ defer s.mu.Unlock()
103+ s.chromeClears = append(s.chromeClears, name)
104+}
99 105
100 // writeMockExtension creates a minimal extension on disk that uses a106 // writeMockExtension creates a minimal extension on disk that uses a
101 // shell script (or batch file on windows) to drive the protocol. The107 // shell script (or batch file on windows) to drive the protocol. The
M packages/agent/extproto/extproto.go +24 -3
@@ -17,7 +17,10 @@
1717 // correlate; events and notifications never carry an ID.
1818 package extproto
1919
20-import "encoding/json"
20+import (
21+ "encoding/json"
22+ "strings"
23+)
2124
2225 const ProtocolVersion = 1
2326
@@ -136,9 +139,27 @@ type StatusFromExt struct {
136139 Text string `json:"text,omitempty"`
137140 }
138141
142+const (
143+ WidgetPositionAboveInput = "above_input"
144+ WidgetPositionRightBar = "right_bar"
145+)
146+
147+// NormalizeWidgetPosition returns the host's canonical widget placement.
148+// Empty and unknown values intentionally retain the historical above-input
149+// behavior so older extensions and future placement hints remain compatible.
150+func NormalizeWidgetPosition(position string) string {
151+ switch strings.ToLower(strings.TrimSpace(position)) {
152+ case WidgetPositionRightBar:
153+ return WidgetPositionRightBar
154+ default:
155+ return WidgetPositionAboveInput
156+ }
157+}
158+
139159 // WidgetFromExt sets or replaces one persistent widget owned by an
140-// extension. Position is a host-defined placement hint; interactive hosts
141-// currently support "above_input" and treat unknown values as that default.
160+// extension. Position is a host-defined placement hint. Interactive hosts
161+// support "above_input" and "right_bar"; empty and unknown values retain
162+// the historical "above_input" behavior.
142163 type WidgetFromExt struct {
143164 Type string `json:"type"`
144165 ID string `json:"id"`
@@ -17,7 +17,10 @@
17 // correlate; events and notifications never carry an ID.17 // correlate; events and notifications never carry an ID.
18 package extproto18 package extproto
19 19
20-import "encoding/json"20+import (
21+ "encoding/json"
22+ "strings"
23+)
21 24
22 const ProtocolVersion = 125 const ProtocolVersion = 1
23 26
@@ -136,9 +139,27 @@ type StatusFromExt struct {
136 Text string `json:"text,omitempty"`139 Text string `json:"text,omitempty"`
137 }140 }
138 141
142+const (
143+ WidgetPositionAboveInput = "above_input"
144+ WidgetPositionRightBar = "right_bar"
145+)
146+
147+// NormalizeWidgetPosition returns the host's canonical widget placement.
148+// Empty and unknown values intentionally retain the historical above-input
149+// behavior so older extensions and future placement hints remain compatible.
150+func NormalizeWidgetPosition(position string) string {
151+ switch strings.ToLower(strings.TrimSpace(position)) {
152+ case WidgetPositionRightBar:
153+ return WidgetPositionRightBar
154+ default:
155+ return WidgetPositionAboveInput
156+ }
157+}
158+
139 // WidgetFromExt sets or replaces one persistent widget owned by an159 // WidgetFromExt sets or replaces one persistent widget owned by an
140-// extension. Position is a host-defined placement hint; interactive hosts160+// extension. Position is a host-defined placement hint. Interactive hosts
141-// currently support "above_input" and treat unknown values as that default.161+// support "above_input" and "right_bar"; empty and unknown values retain
162+// the historical "above_input" behavior.
142 type WidgetFromExt struct {163 type WidgetFromExt struct {
143 Type string `json:"type"`164 Type string `json:"type"`
144 ID string `json:"id"`165 ID string `json:"id"`
A packages/agent/extproto/widget_test.go +39 -0
@@ -0,0 +1,39 @@
1+package extproto
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+func TestNormalizeWidgetPosition(t *testing.T) {
9+ cases := map[string]string{
10+ WidgetPositionRightBar: WidgetPositionRightBar,
11+ " RIGHT_BAR ": WidgetPositionRightBar,
12+ WidgetPositionAboveInput: WidgetPositionAboveInput,
13+ "": WidgetPositionAboveInput,
14+ "unknown": WidgetPositionAboveInput,
15+ }
16+ for input, want := range cases {
17+ if got := NormalizeWidgetPosition(input); got != want {
18+ t.Errorf("NormalizeWidgetPosition(%q) = %q, want %q", input, got, want)
19+ }
20+ }
21+}
22+
23+func TestWidgetFrameKeepsUnknownAndMissingPositionsCompatible(t *testing.T) {
24+ var missing WidgetFromExt
25+ if err := json.Unmarshal([]byte(`{"type":"widget","id":"plan","title":"Plan"}`), &missing); err != nil {
26+ t.Fatal(err)
27+ }
28+ if got := NormalizeWidgetPosition(missing.Position); got != WidgetPositionAboveInput {
29+ t.Fatalf("missing position normalized to %q, want %q", got, WidgetPositionAboveInput)
30+ }
31+
32+ var rightBar WidgetFromExt
33+ if err := json.Unmarshal([]byte(`{"type":"widget","id":"plan","position":"right_bar","title":"Plan"}`), &rightBar); err != nil {
34+ t.Fatal(err)
35+ }
36+ if got := NormalizeWidgetPosition(rightBar.Position); got != WidgetPositionRightBar {
37+ t.Fatalf("right_bar position normalized to %q, want %q", got, WidgetPositionRightBar)
38+ }
39+}
@@ -0,0 +1,39 @@
1+package extproto
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+func TestNormalizeWidgetPosition(t *testing.T) {
9+ cases := map[string]string{
10+ WidgetPositionRightBar: WidgetPositionRightBar,
11+ " RIGHT_BAR ": WidgetPositionRightBar,
12+ WidgetPositionAboveInput: WidgetPositionAboveInput,
13+ "": WidgetPositionAboveInput,
14+ "unknown": WidgetPositionAboveInput,
15+ }
16+ for input, want := range cases {
17+ if got := NormalizeWidgetPosition(input); got != want {
18+ t.Errorf("NormalizeWidgetPosition(%q) = %q, want %q", input, got, want)
19+ }
20+ }
21+}
22+
23+func TestWidgetFrameKeepsUnknownAndMissingPositionsCompatible(t *testing.T) {
24+ var missing WidgetFromExt
25+ if err := json.Unmarshal([]byte(`{"type":"widget","id":"plan","title":"Plan"}`), &missing); err != nil {
26+ t.Fatal(err)
27+ }
28+ if got := NormalizeWidgetPosition(missing.Position); got != WidgetPositionAboveInput {
29+ t.Fatalf("missing position normalized to %q, want %q", got, WidgetPositionAboveInput)
30+ }
31+
32+ var rightBar WidgetFromExt
33+ if err := json.Unmarshal([]byte(`{"type":"widget","id":"plan","position":"right_bar","title":"Plan"}`), &rightBar); err != nil {
34+ t.Fatal(err)
35+ }
36+ if got := NormalizeWidgetPosition(rightBar.Position); got != WidgetPositionRightBar {
37+ t.Fatalf("right_bar position normalized to %q, want %q", got, WidgetPositionRightBar)
38+ }
39+}
M packages/agent/modes/ext_notes_test.go +69 -0
@@ -109,3 +109,72 @@ func TestPersistentExtensionStatusAndWidget(t *testing.T) {
109109 t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
110110 }
111111 }
112+
113+func TestRightBarWidgetsAreDeterministicAndFallbackAboveInput(t *testing.T) {
114+ i := newNotesTestInteractive()
115+ i.SetWidget("zeta", "plan", "right_bar", "Zeta", []string{"zeta line"})
116+ i.SetWidget("alpha", "second", "right_bar", "Second", []string{"second line"})
117+ i.SetWidget("alpha", "first", "right_bar", "First", []string{"first line"})
118+ i.SetWidget("legacy", "plan", "above_input", "Legacy", []string{"legacy line"})
119+
120+ i.mu.Lock()
121+ widgets := i.rightBarWidgetsLocked()
122+ wideAbove := i.extensionChromeLinesAtLocked(80, true)
123+ narrowFallback := i.extensionChromeLinesAtLocked(72, false)
124+ i.mu.Unlock()
125+
126+ if len(widgets) != 3 || widgets[0].Extension != "alpha" || widgets[0].ID != "first" || widgets[1].ID != "second" || widgets[2].Extension != "zeta" {
127+ t.Fatalf("right-bar ordering = %+v", widgets)
128+ }
129+ wideText := strings.Join(wideAbove, "\n")
130+ if strings.Contains(wideText, "Zeta") || strings.Contains(wideText, "Second") || strings.Contains(wideText, "First") {
131+ t.Fatalf("right-bar widgets leaked into wide above-input chrome: %q", wideText)
132+ }
133+ fallbackText := strings.Join(narrowFallback, "\n")
134+ if !strings.Contains(fallbackText, "Zeta") || !strings.Contains(fallbackText, "First") || !strings.Contains(fallbackText, "Legacy") {
135+ t.Fatalf("narrow fallback omitted widgets: %q", fallbackText)
136+ }
137+}
138+
139+func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) {
140+ i := newNotesTestInteractive()
141+ i.SetStatus("gone", "progress", "success", "done")
142+ i.SetWidget("gone", "plan", "right_bar", "Gone", []string{"old"})
143+ i.SetStatus("keep", "progress", "info", "still here")
144+ i.SetWidget("keep", "plan", "right_bar", "Keep", []string{"new"})
145+ i.extNotes = []string{"[gone] old note", "[keep] keep note"}
146+
147+ i.ClearExtensionChrome("gone")
148+
149+ if _, ok := i.extStatuses["gone"]; ok {
150+ t.Fatal("exited extension status survived")
151+ }
152+ if _, ok := i.extWidgets["gone"]; ok {
153+ t.Fatal("exited extension widget survived")
154+ }
155+ if _, ok := i.extStatuses["keep"]; !ok {
156+ t.Fatal("unrelated extension status was removed")
157+ }
158+ if _, ok := i.extWidgets["keep"]; !ok {
159+ t.Fatal("unrelated extension widget was removed")
160+ }
161+ if got := strings.Join(i.extNotes, "\n"); got != "[keep] keep note" {
162+ t.Fatalf("extension notes after cleanup = %q", got)
163+ }
164+}
165+
166+func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) {
167+ term := &alertTestTerminal{}
168+ i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
169+ i.rend.Resize(80, 24)
170+ i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"})
171+ i.redraw()
172+
173+ out := term.String()
174+ if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") {
175+ t.Fatalf("wide redraw omitted right-bar content: %q", out)
176+ }
177+ if !strings.Contains(out, "│") {
178+ t.Fatal("wide redraw omitted the main-pane divider")
179+ }
180+}
@@ -109,3 +109,72 @@ func TestPersistentExtensionStatusAndWidget(t *testing.T) {
109 t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)109 t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets)
110 }110 }
111 }111 }
112+
113+func TestRightBarWidgetsAreDeterministicAndFallbackAboveInput(t *testing.T) {
114+ i := newNotesTestInteractive()
115+ i.SetWidget("zeta", "plan", "right_bar", "Zeta", []string{"zeta line"})
116+ i.SetWidget("alpha", "second", "right_bar", "Second", []string{"second line"})
117+ i.SetWidget("alpha", "first", "right_bar", "First", []string{"first line"})
118+ i.SetWidget("legacy", "plan", "above_input", "Legacy", []string{"legacy line"})
119+
120+ i.mu.Lock()
121+ widgets := i.rightBarWidgetsLocked()
122+ wideAbove := i.extensionChromeLinesAtLocked(80, true)
123+ narrowFallback := i.extensionChromeLinesAtLocked(72, false)
124+ i.mu.Unlock()
125+
126+ if len(widgets) != 3 || widgets[0].Extension != "alpha" || widgets[0].ID != "first" || widgets[1].ID != "second" || widgets[2].Extension != "zeta" {
127+ t.Fatalf("right-bar ordering = %+v", widgets)
128+ }
129+ wideText := strings.Join(wideAbove, "\n")
130+ if strings.Contains(wideText, "Zeta") || strings.Contains(wideText, "Second") || strings.Contains(wideText, "First") {
131+ t.Fatalf("right-bar widgets leaked into wide above-input chrome: %q", wideText)
132+ }
133+ fallbackText := strings.Join(narrowFallback, "\n")
134+ if !strings.Contains(fallbackText, "Zeta") || !strings.Contains(fallbackText, "First") || !strings.Contains(fallbackText, "Legacy") {
135+ t.Fatalf("narrow fallback omitted widgets: %q", fallbackText)
136+ }
137+}
138+
139+func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) {
140+ i := newNotesTestInteractive()
141+ i.SetStatus("gone", "progress", "success", "done")
142+ i.SetWidget("gone", "plan", "right_bar", "Gone", []string{"old"})
143+ i.SetStatus("keep", "progress", "info", "still here")
144+ i.SetWidget("keep", "plan", "right_bar", "Keep", []string{"new"})
145+ i.extNotes = []string{"[gone] old note", "[keep] keep note"}
146+
147+ i.ClearExtensionChrome("gone")
148+
149+ if _, ok := i.extStatuses["gone"]; ok {
150+ t.Fatal("exited extension status survived")
151+ }
152+ if _, ok := i.extWidgets["gone"]; ok {
153+ t.Fatal("exited extension widget survived")
154+ }
155+ if _, ok := i.extStatuses["keep"]; !ok {
156+ t.Fatal("unrelated extension status was removed")
157+ }
158+ if _, ok := i.extWidgets["keep"]; !ok {
159+ t.Fatal("unrelated extension widget was removed")
160+ }
161+ if got := strings.Join(i.extNotes, "\n"); got != "[keep] keep note" {
162+ t.Fatalf("extension notes after cleanup = %q", got)
163+ }
164+}
165+
166+func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) {
167+ term := &alertTestTerminal{}
168+ i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
169+ i.rend.Resize(80, 24)
170+ i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"})
171+ i.redraw()
172+
173+ out := term.String()
174+ if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") {
175+ t.Fatalf("wide redraw omitted right-bar content: %q", out)
176+ }
177+ if !strings.Contains(out, "│") {
178+ t.Fatal("wide redraw omitted the main-pane divider")
179+ }
180+}
M packages/agent/modes/interactive.go +124 -38
@@ -1211,7 +1211,30 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
12111211 const maxExtensionWidgetRows = 12
12121212 const maxExtensionStatusRows = 6
12131213
1214+func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1215+ var out []tui.RightBarWidget
1216+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1217+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1218+ widget := i.extWidgets[extName][id]
1219+ if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
1220+ continue
1221+ }
1222+ out = append(out, tui.RightBarWidget{
1223+ Extension: extName,
1224+ ID: id,
1225+ Title: widget.Title,
1226+ Lines: append([]string(nil), widget.Lines...),
1227+ })
1228+ }
1229+ }
1230+ return out
1231+}
1232+
12141233 func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1234+ return i.extensionChromeLinesAtLocked(cols, false)
1235+}
1236+
1237+func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
12151238 var out []string
12161239 bodyWidth := cols - 2
12171240 if bodyWidth < 1 {
@@ -1236,6 +1259,9 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
12361259 }
12371260 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
12381261 widget := i.extWidgets[extName][id]
1262+ if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
1263+ continue
1264+ }
12391265 label := " [" + extName + "]"
12401266 if widget.Title != "" {
12411267 label += " " + widget.Title
@@ -1536,20 +1562,31 @@ func (i *Interactive) redraw() {
15361562 i.mu.Lock()
15371563 defer i.mu.Unlock()
15381564
1539- cols, _ := i.cfg.Terminal.Size()
1540- chat := i.cachedChatLocked(cols)
1565+ cols, rows := i.cfg.Terminal.Size()
1566+ mainCols := cols
1567+ rightBarWidgets := i.rightBarWidgetsLocked()
1568+ rightBarActive := false
1569+ var rightBarWidth int
1570+ if len(rightBarWidgets) > 0 {
1571+ if width, rail, ok := tui.RightBarColumns(cols); ok {
1572+ mainCols = width
1573+ rightBarWidth = rail
1574+ rightBarActive = true
1575+ }
1576+ }
1577+ chat := i.cachedChatLocked(mainCols)
15411578
15421579 // Dialogs (login or model picker) render between chat and the editor.
15431580 var dialog []string
15441581 switch {
15451582 case i.dialog.Active():
1546- dialog = i.dialog.Render(i.cfg.Theme, cols)
1583+ dialog = i.dialog.Render(i.cfg.Theme, mainCols)
15471584 case i.modelDialog.Active():
1548- dialog = i.modelDialog.Render(i.cfg.Theme, cols)
1585+ dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
15491586 case i.llamaDialog.Active():
1550- dialog = i.llamaDialog.Render(i.cfg.Theme, cols)
1587+ dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
15511588 case i.rescueDialog.Active():
1552- dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
1589+ dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
15531590 case i.sessionDialog.Active():
15541591 // Reserve rows for the editor (~3), status line (1-2),
15551592 // dialog chrome (header + hint + rule + indicators, ~5),
@@ -1561,36 +1598,36 @@ func (i *Interactive) redraw() {
15611598 avail = 3
15621599 }
15631600 i.sessionDialog.MaxRows = avail
1564- dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
1601+ dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
15651602 case i.subagentsDialog.Active():
1566- dialog = i.subagentsDialog.Render(i.cfg.Theme, cols)
1603+ dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
15671604 case i.jumpDialog.Active():
1568- dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
1605+ dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
15691606 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1570- dialog = i.extPanel.Render(i.cfg.Theme, cols)
1607+ dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
15711608 case i.confirmDialog.Active():
15721609 if i.btwDialog.Active() {
15731610 // Keep the side-chat transcript and its live tool preview visible
15741611 // above confirmation, matching how the main transcript remains
15751612 // visible while --no-yolo waits for a decision.
1576- dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)
1613+ dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
15771614 } else {
1578- dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
1615+ dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
15791616 }
15801617 case i.btwDialog.Active():
1581- dialog = i.btwDialog.Render(i.cfg.Theme, cols)
1618+ dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
15821619 case i.skillsDialog.Active():
1583- dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
1620+ dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
15841621 case i.changelogDialog.Active():
1585- dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
1622+ dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
15861623 case i.logoutDialog.Active():
1587- dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
1624+ dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
15881625 case i.telegramDialog.Active():
1589- dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
1626+ dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
15901627 case i.settingsDialog.Active():
1591- dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
1628+ dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
15921629 case i.sessionOpsDialog.Active():
1593- dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
1630+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
15941631 case i.sessionTreeDialog.Active():
15951632 // Reserve rows for the editor, status, and tree chrome while using
15961633 // the same budget for rendering and PageUp/PageDown movement.
@@ -1600,7 +1637,7 @@ func (i *Interactive) redraw() {
16001637 avail = 3
16011638 }
16021639 i.sessionTreeDialog.MaxRows = avail
1603- dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
1640+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
16041641 }
16051642 if len(dialog) > 0 {
16061643 dialog = padDialogFrame(dialog)
@@ -1649,9 +1686,9 @@ func (i *Interactive) redraw() {
16491686 i.fileSuggest.SetCWD(i.cfg.CWD)
16501687 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive())
16511688 if mainInputFocused && i.suggest.Active(currentInput) {
1652- suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
1689+ suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
16531690 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
1654- suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
1691+ suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
16551692 }
16561693
16571694 // Detect overlay close (any dialog or slash/file suggestion popup
@@ -1725,7 +1762,7 @@ func (i *Interactive) redraw() {
17251762 ContextMax: ctxMax,
17261763 AutoCompacting: i.autoCompacting,
17271764 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1728- Cols: cols,
1765+ Cols: mainCols,
17291766 })
17301767 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
17311768 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
@@ -1733,7 +1770,7 @@ func (i *Interactive) redraw() {
17331770 } else {
17341771 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
17351772 }
1736- edLines, curR, curC := i.ed.Render(cols)
1773+ edLines, curR, curC := i.ed.Render(mainCols)
17371774 var workingLines []string
17381775 if busyPrefix != "" && !workingWithStatus {
17391776 workingLines = []string{" " + busyPrefix}
@@ -1742,10 +1779,10 @@ func (i *Interactive) redraw() {
17421779 inputCursorColOffset := 0
17431780 switch inputStyle {
17441781 case tui.InputStyleLines:
1745- edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1782+ edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
17461783 inputCursorOffset = 1
17471784 case tui.InputStyleBlock:
1748- edLines = tui.InputBlock(i.cfg.Theme, edLines, cols)
1785+ edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
17491786 inputCursorOffset = 1
17501787 inputCursorColOffset = 2
17511788 }
@@ -1762,7 +1799,7 @@ func (i *Interactive) redraw() {
17621799 queue = append(queue, "")
17631800 for _, q := range queued {
17641801 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1765- text := truncateLine(q, cols-17)
1802+ text := truncateLine(q, mainCols-17)
17661803 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
17671804 }
17681805 // Hint row, rendered in the same muted tone as the model
@@ -1773,7 +1810,7 @@ func (i *Interactive) redraw() {
17731810 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
17741811 }
17751812
1776- extensionLines := i.extensionChromeLinesLocked(cols)
1813+ extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive)
17771814
17781815 // Bottom-sticky sections (always visible, never scroll). Each
17791816 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -1837,8 +1874,12 @@ func (i *Interactive) redraw() {
18371874 }
18381875 }
18391876
1840- _, rows := i.cfg.Terminal.Size()
18411877 chatRows := rows - len(bottom)
1878+ if rightBarActive {
1879+ // DrawRightBar keeps the renderer-owned bottom margin inside the
1880+ // fixed frame, so reserve that row before selecting visible chat.
1881+ chatRows--
1882+ }
18421883 if chatRows < 1 {
18431884 chatRows = 1
18441885 }
@@ -1866,12 +1907,12 @@ func (i *Interactive) redraw() {
18661907 // let a shrinking chatRows pull the window toward the tail, which
18671908 // read as the viewport jumping to the bottom whenever the agent
18681909 // streamed text or a tool call grew the bottom band.
1869- if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1910+ if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
18701911 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
18711912 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
18721913 }
18731914 i.prevChatLen = len(chat)
1874- i.prevChatCols = cols
1915+ i.prevChatCols = mainCols
18751916 i.prevChatRows = chatRows
18761917
18771918 // Apply scroll offset to the chat slice.
@@ -1893,7 +1934,7 @@ func (i *Interactive) redraw() {
18931934 i.view.TailLimit = 0 // unbounded
18941935 }
18951936 i.chatCacheValid = false
1896- chat = i.cachedChatLocked(cols)
1937+ chat = i.cachedChatLocked(mainCols)
18971938 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
18981939 chat = chat[:len(chat)-1]
18991940 }
@@ -1909,7 +1950,7 @@ func (i *Interactive) redraw() {
19091950 i.scrollOffset += grew
19101951 }
19111952 i.prevChatLen = len(chat)
1912- i.prevChatCols = cols
1953+ i.prevChatCols = mainCols
19131954 maxOffset = len(chat) - chatRows
19141955 if maxOffset < 0 {
19151956 maxOffset = 0
@@ -1994,13 +2035,13 @@ func (i *Interactive) redraw() {
19942035 cursorRow := inputStartRow + inputCursorOffset + curR
19952036 cursorCol := curC + inputCursorColOffset
19962037 if i.btwDialog.Active() {
1997- if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
2038+ if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
19982039 cursorRow = dialogLead + r
19992040 cursorCol = c
20002041 }
20012042 }
20022043 if i.dialog.Active() {
2003- if r, c := i.dialog.CursorPos(cols); r >= 0 {
2044+ if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
20042045 cursorRow = dialogLead + r
20052046 cursorCol = c
20062047 }
@@ -2027,7 +2068,7 @@ func (i *Interactive) redraw() {
20272068 }
20282069 }
20292070 if i.subagentsDialog.Active() {
2030- if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 {
2071+ if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
20312072 cursorRow = dialogLead + r
20322073 cursorCol = c
20332074 } else {
@@ -2043,8 +2084,13 @@ func (i *Interactive) redraw() {
20432084 cursorRow = -1
20442085 cursorCol = 0
20452086 }
2046- _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2047- i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2087+ if rightBarActive {
2088+ rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2089+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2090+ } else {
2091+ _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2092+ i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2093+ }
20482094 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
20492095 alert := *i.pendingAlert
20502096 i.pendingAlert = nil
@@ -3704,6 +3750,7 @@ func (i *Interactive) SetWidget(extName, id, position, title string, lines []str
37043750 if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
37053751 return
37063752 }
3753+ position = extproto.NormalizeWidgetPosition(position)
37073754 i.mu.Lock()
37083755 if i.extWidgets == nil {
37093756 i.extWidgets = map[string]map[string]extensionWidget{}
@@ -3733,6 +3780,45 @@ func (i *Interactive) ClearWidget(extName, id string) {
37333780 i.invalidate()
37343781 }
37353782
3783+// ClearExtensionChrome removes every persistent UI item owned by an
3784+// extension that has exited. This keeps crashes, disable/reload operations,
3785+// and startup failures from leaving stale widgets beside the transcript.
3786+func (i *Interactive) ClearExtensionChrome(extName string) {
3787+ if strings.TrimSpace(extName) == "" {
3788+ return
3789+ }
3790+ marker := "[" + extName + "] "
3791+ i.mu.Lock()
3792+ changed := false
3793+ if _, ok := i.extStatuses[extName]; ok {
3794+ delete(i.extStatuses, extName)
3795+ changed = true
3796+ }
3797+ if _, ok := i.extWidgets[extName]; ok {
3798+ delete(i.extWidgets, extName)
3799+ changed = true
3800+ }
3801+ if len(i.extNotes) > 0 {
3802+ kept := i.extNotes[:0:0]
3803+ for _, line := range i.extNotes {
3804+ if strings.Contains(line, marker) {
3805+ changed = true
3806+ continue
3807+ }
3808+ kept = append(kept, line)
3809+ }
3810+ i.extNotes = kept
3811+ }
3812+ if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
3813+ i.extPanel.Close()
3814+ changed = true
3815+ }
3816+ i.mu.Unlock()
3817+ if changed {
3818+ i.invalidate()
3819+ }
3820+}
3821+
37363822 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
37373823 i.mu.Lock()
37383824 defer i.mu.Unlock()
@@ -1211,7 +1211,30 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
1211 const maxExtensionWidgetRows = 121211 const maxExtensionWidgetRows = 12
1212 const maxExtensionStatusRows = 61212 const maxExtensionStatusRows = 6
1213 1213
1214+func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1215+ var out []tui.RightBarWidget
1216+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1217+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1218+ widget := i.extWidgets[extName][id]
1219+ if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
1220+ continue
1221+ }
1222+ out = append(out, tui.RightBarWidget{
1223+ Extension: extName,
1224+ ID: id,
1225+ Title: widget.Title,
1226+ Lines: append([]string(nil), widget.Lines...),
1227+ })
1228+ }
1229+ }
1230+ return out
1231+}
1232+
1214 func (i *Interactive) extensionChromeLinesLocked(cols int) []string {1233 func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1234+ return i.extensionChromeLinesAtLocked(cols, false)
1235+}
1236+
1237+func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
1215 var out []string1238 var out []string
1216 bodyWidth := cols - 21239 bodyWidth := cols - 2
1217 if bodyWidth < 1 {1240 if bodyWidth < 1 {
@@ -1236,6 +1259,9 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1236 }1259 }
1237 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {1260 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1238 widget := i.extWidgets[extName][id]1261 widget := i.extWidgets[extName][id]
1262+ if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
1263+ continue
1264+ }
1239 label := " [" + extName + "]"1265 label := " [" + extName + "]"
1240 if widget.Title != "" {1266 if widget.Title != "" {
1241 label += " " + widget.Title1267 label += " " + widget.Title
@@ -1536,20 +1562,31 @@ func (i *Interactive) redraw() {
1536 i.mu.Lock()1562 i.mu.Lock()
1537 defer i.mu.Unlock()1563 defer i.mu.Unlock()
1538 1564
1539- cols, _ := i.cfg.Terminal.Size()1565+ cols, rows := i.cfg.Terminal.Size()
1540- chat := i.cachedChatLocked(cols)1566+ mainCols := cols
1567+ rightBarWidgets := i.rightBarWidgetsLocked()
1568+ rightBarActive := false
1569+ var rightBarWidth int
1570+ if len(rightBarWidgets) > 0 {
1571+ if width, rail, ok := tui.RightBarColumns(cols); ok {
1572+ mainCols = width
1573+ rightBarWidth = rail
1574+ rightBarActive = true
1575+ }
1576+ }
1577+ chat := i.cachedChatLocked(mainCols)
1541 1578
1542 // Dialogs (login or model picker) render between chat and the editor.1579 // Dialogs (login or model picker) render between chat and the editor.
1543 var dialog []string1580 var dialog []string
1544 switch {1581 switch {
1545 case i.dialog.Active():1582 case i.dialog.Active():
1546- dialog = i.dialog.Render(i.cfg.Theme, cols)1583+ dialog = i.dialog.Render(i.cfg.Theme, mainCols)
1547 case i.modelDialog.Active():1584 case i.modelDialog.Active():
1548- dialog = i.modelDialog.Render(i.cfg.Theme, cols)1585+ dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
1549 case i.llamaDialog.Active():1586 case i.llamaDialog.Active():
1550- dialog = i.llamaDialog.Render(i.cfg.Theme, cols)1587+ dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
1551 case i.rescueDialog.Active():1588 case i.rescueDialog.Active():
1552- dialog = i.rescueDialog.Render(i.cfg.Theme, cols)1589+ dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
1553 case i.sessionDialog.Active():1590 case i.sessionDialog.Active():
1554 // Reserve rows for the editor (~3), status line (1-2),1591 // Reserve rows for the editor (~3), status line (1-2),
1555 // dialog chrome (header + hint + rule + indicators, ~5),1592 // dialog chrome (header + hint + rule + indicators, ~5),
@@ -1561,36 +1598,36 @@ func (i *Interactive) redraw() {
1561 avail = 31598 avail = 3
1562 }1599 }
1563 i.sessionDialog.MaxRows = avail1600 i.sessionDialog.MaxRows = avail
1564- dialog = i.sessionDialog.Render(i.cfg.Theme, cols)1601+ dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1565 case i.subagentsDialog.Active():1602 case i.subagentsDialog.Active():
1566- dialog = i.subagentsDialog.Render(i.cfg.Theme, cols)1603+ dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
1567 case i.jumpDialog.Active():1604 case i.jumpDialog.Active():
1568- dialog = i.jumpDialog.Render(i.cfg.Theme, cols)1605+ dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
1569 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):1606 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1570- dialog = i.extPanel.Render(i.cfg.Theme, cols)1607+ dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
1571 case i.confirmDialog.Active():1608 case i.confirmDialog.Active():
1572 if i.btwDialog.Active() {1609 if i.btwDialog.Active() {
1573 // Keep the side-chat transcript and its live tool preview visible1610 // Keep the side-chat transcript and its live tool preview visible
1574 // above confirmation, matching how the main transcript remains1611 // above confirmation, matching how the main transcript remains
1575 // visible while --no-yolo waits for a decision.1612 // visible while --no-yolo waits for a decision.
1576- dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)1613+ dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
1577 } else {1614 } else {
1578- dialog = i.confirmDialog.Render(i.cfg.Theme, cols)1615+ dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
1579 }1616 }
1580 case i.btwDialog.Active():1617 case i.btwDialog.Active():
1581- dialog = i.btwDialog.Render(i.cfg.Theme, cols)1618+ dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
1582 case i.skillsDialog.Active():1619 case i.skillsDialog.Active():
1583- dialog = i.skillsDialog.Render(i.cfg.Theme, cols)1620+ dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
1584 case i.changelogDialog.Active():1621 case i.changelogDialog.Active():
1585- dialog = i.changelogDialog.Render(i.cfg.Theme, cols)1622+ dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
1586 case i.logoutDialog.Active():1623 case i.logoutDialog.Active():
1587- dialog = i.logoutDialog.Render(i.cfg.Theme, cols)1624+ dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
1588 case i.telegramDialog.Active():1625 case i.telegramDialog.Active():
1589- dialog = i.telegramDialog.Render(i.cfg.Theme, cols)1626+ dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
1590 case i.settingsDialog.Active():1627 case i.settingsDialog.Active():
1591- dialog = i.settingsDialog.Render(i.cfg.Theme, cols)1628+ dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
1592 case i.sessionOpsDialog.Active():1629 case i.sessionOpsDialog.Active():
1593- dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)1630+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
1594 case i.sessionTreeDialog.Active():1631 case i.sessionTreeDialog.Active():
1595 // Reserve rows for the editor, status, and tree chrome while using1632 // Reserve rows for the editor, status, and tree chrome while using
1596 // the same budget for rendering and PageUp/PageDown movement.1633 // the same budget for rendering and PageUp/PageDown movement.
@@ -1600,7 +1637,7 @@ func (i *Interactive) redraw() {
1600 avail = 31637 avail = 3
1601 }1638 }
1602 i.sessionTreeDialog.MaxRows = avail1639 i.sessionTreeDialog.MaxRows = avail
1603- dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)1640+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
1604 }1641 }
1605 if len(dialog) > 0 {1642 if len(dialog) > 0 {
1606 dialog = padDialogFrame(dialog)1643 dialog = padDialogFrame(dialog)
@@ -1649,9 +1686,9 @@ func (i *Interactive) redraw() {
1649 i.fileSuggest.SetCWD(i.cfg.CWD)1686 i.fileSuggest.SetCWD(i.cfg.CWD)
1650 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive())1687 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive())
1651 if mainInputFocused && i.suggest.Active(currentInput) {1688 if mainInputFocused && i.suggest.Active(currentInput) {
1652- suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)1689+ suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
1653 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {1690 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
1654- suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)1691+ suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
1655 }1692 }
1656 1693
1657 // Detect overlay close (any dialog or slash/file suggestion popup1694 // Detect overlay close (any dialog or slash/file suggestion popup
@@ -1725,7 +1762,7 @@ func (i *Interactive) redraw() {
1725 ContextMax: ctxMax,1762 ContextMax: ctxMax,
1726 AutoCompacting: i.autoCompacting,1763 AutoCompacting: i.autoCompacting,
1727 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),1764 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1728- Cols: cols,1765+ Cols: mainCols,
1729 })1766 })
1730 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)1767 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
1731 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {1768 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
@@ -1733,7 +1770,7 @@ func (i *Interactive) redraw() {
1733 } else {1770 } else {
1734 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)1771 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1735 }1772 }
1736- edLines, curR, curC := i.ed.Render(cols)1773+ edLines, curR, curC := i.ed.Render(mainCols)
1737 var workingLines []string1774 var workingLines []string
1738 if busyPrefix != "" && !workingWithStatus {1775 if busyPrefix != "" && !workingWithStatus {
1739 workingLines = []string{" " + busyPrefix}1776 workingLines = []string{" " + busyPrefix}
@@ -1742,10 +1779,10 @@ func (i *Interactive) redraw() {
1742 inputCursorColOffset := 01779 inputCursorColOffset := 0
1743 switch inputStyle {1780 switch inputStyle {
1744 case tui.InputStyleLines:1781 case tui.InputStyleLines:
1745- edLines = tui.InputLines(i.cfg.Theme, edLines, cols)1782+ edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
1746 inputCursorOffset = 11783 inputCursorOffset = 1
1747 case tui.InputStyleBlock:1784 case tui.InputStyleBlock:
1748- edLines = tui.InputBlock(i.cfg.Theme, edLines, cols)1785+ edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
1749 inputCursorOffset = 11786 inputCursorOffset = 1
1750 inputCursorColOffset = 21787 inputCursorColOffset = 2
1751 }1788 }
@@ -1762,7 +1799,7 @@ func (i *Interactive) redraw() {
1762 queue = append(queue, "")1799 queue = append(queue, "")
1763 for _, q := range queued {1800 for _, q := range queued {
1764 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")1801 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1765- text := truncateLine(q, cols-17)1802+ text := truncateLine(q, mainCols-17)
1766 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))1803 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
1767 }1804 }
1768 // Hint row, rendered in the same muted tone as the model1805 // Hint row, rendered in the same muted tone as the model
@@ -1773,7 +1810,7 @@ func (i *Interactive) redraw() {
1773 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))1810 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1774 }1811 }
1775 1812
1776- extensionLines := i.extensionChromeLinesLocked(cols)1813+ extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive)
1777 1814
1778 // Bottom-sticky sections (always visible, never scroll). Each1815 // Bottom-sticky sections (always visible, never scroll). Each
1779 // non-empty subsection (dialog, suggest popup, sliding-in queue)1816 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -1837,8 +1874,12 @@ func (i *Interactive) redraw() {
1837 }1874 }
1838 }1875 }
1839 1876
1840- _, rows := i.cfg.Terminal.Size()
1841 chatRows := rows - len(bottom)1877 chatRows := rows - len(bottom)
1878+ if rightBarActive {
1879+ // DrawRightBar keeps the renderer-owned bottom margin inside the
1880+ // fixed frame, so reserve that row before selecting visible chat.
1881+ chatRows--
1882+ }
1842 if chatRows < 1 {1883 if chatRows < 1 {
1843 chatRows = 11884 chatRows = 1
1844 }1885 }
@@ -1866,12 +1907,12 @@ func (i *Interactive) redraw() {
1866 // let a shrinking chatRows pull the window toward the tail, which1907 // let a shrinking chatRows pull the window toward the tail, which
1867 // read as the viewport jumping to the bottom whenever the agent1908 // read as the viewport jumping to the bottom whenever the agent
1868 // streamed text or a tool call grew the bottom band.1909 // streamed text or a tool call grew the bottom band.
1869- if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {1910+ if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
1870 i.scrollOffset = anchorScrollOffset(i.scrollOffset,1911 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
1871 i.prevChatLen, len(chat), i.prevChatRows, chatRows)1912 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
1872 }1913 }
1873 i.prevChatLen = len(chat)1914 i.prevChatLen = len(chat)
1874- i.prevChatCols = cols1915+ i.prevChatCols = mainCols
1875 i.prevChatRows = chatRows1916 i.prevChatRows = chatRows
1876 1917
1877 // Apply scroll offset to the chat slice.1918 // Apply scroll offset to the chat slice.
@@ -1893,7 +1934,7 @@ func (i *Interactive) redraw() {
1893 i.view.TailLimit = 0 // unbounded1934 i.view.TailLimit = 0 // unbounded
1894 }1935 }
1895 i.chatCacheValid = false1936 i.chatCacheValid = false
1896- chat = i.cachedChatLocked(cols)1937+ chat = i.cachedChatLocked(mainCols)
1897 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {1938 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1898 chat = chat[:len(chat)-1]1939 chat = chat[:len(chat)-1]
1899 }1940 }
@@ -1909,7 +1950,7 @@ func (i *Interactive) redraw() {
1909 i.scrollOffset += grew1950 i.scrollOffset += grew
1910 }1951 }
1911 i.prevChatLen = len(chat)1952 i.prevChatLen = len(chat)
1912- i.prevChatCols = cols1953+ i.prevChatCols = mainCols
1913 maxOffset = len(chat) - chatRows1954 maxOffset = len(chat) - chatRows
1914 if maxOffset < 0 {1955 if maxOffset < 0 {
1915 maxOffset = 01956 maxOffset = 0
@@ -1994,13 +2035,13 @@ func (i *Interactive) redraw() {
1994 cursorRow := inputStartRow + inputCursorOffset + curR2035 cursorRow := inputStartRow + inputCursorOffset + curR
1995 cursorCol := curC + inputCursorColOffset2036 cursorCol := curC + inputCursorColOffset
1996 if i.btwDialog.Active() {2037 if i.btwDialog.Active() {
1997- if r, c := i.btwDialog.CursorPos(cols); r >= 0 {2038+ if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
1998 cursorRow = dialogLead + r2039 cursorRow = dialogLead + r
1999 cursorCol = c2040 cursorCol = c
2000 }2041 }
2001 }2042 }
2002 if i.dialog.Active() {2043 if i.dialog.Active() {
2003- if r, c := i.dialog.CursorPos(cols); r >= 0 {2044+ if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
2004 cursorRow = dialogLead + r2045 cursorRow = dialogLead + r
2005 cursorCol = c2046 cursorCol = c
2006 }2047 }
@@ -2027,7 +2068,7 @@ func (i *Interactive) redraw() {
2027 }2068 }
2028 }2069 }
2029 if i.subagentsDialog.Active() {2070 if i.subagentsDialog.Active() {
2030- if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 {2071+ if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
2031 cursorRow = dialogLead + r2072 cursorRow = dialogLead + r
2032 cursorCol = c2073 cursorCol = c
2033 } else {2074 } else {
@@ -2043,8 +2084,13 @@ func (i *Interactive) redraw() {
2043 cursorRow = -12084 cursorRow = -1
2044 cursorCol = 02085 cursorCol = 0
2045 }2086 }
2046- _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.2087+ if rightBarActive {
2047- i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)2088+ rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2089+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2090+ } else {
2091+ _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2092+ i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2093+ }
2048 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {2094 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
2049 alert := *i.pendingAlert2095 alert := *i.pendingAlert
2050 i.pendingAlert = nil2096 i.pendingAlert = nil
@@ -3704,6 +3750,7 @@ func (i *Interactive) SetWidget(extName, id, position, title string, lines []str
3704 if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {3750 if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
3705 return3751 return
3706 }3752 }
3753+ position = extproto.NormalizeWidgetPosition(position)
3707 i.mu.Lock()3754 i.mu.Lock()
3708 if i.extWidgets == nil {3755 if i.extWidgets == nil {
3709 i.extWidgets = map[string]map[string]extensionWidget{}3756 i.extWidgets = map[string]map[string]extensionWidget{}
@@ -3733,6 +3780,45 @@ func (i *Interactive) ClearWidget(extName, id string) {
3733 i.invalidate()3780 i.invalidate()
3734 }3781 }
3735 3782
3783+// ClearExtensionChrome removes every persistent UI item owned by an
3784+// extension that has exited. This keeps crashes, disable/reload operations,
3785+// and startup failures from leaving stale widgets beside the transcript.
3786+func (i *Interactive) ClearExtensionChrome(extName string) {
3787+ if strings.TrimSpace(extName) == "" {
3788+ return
3789+ }
3790+ marker := "[" + extName + "] "
3791+ i.mu.Lock()
3792+ changed := false
3793+ if _, ok := i.extStatuses[extName]; ok {
3794+ delete(i.extStatuses, extName)
3795+ changed = true
3796+ }
3797+ if _, ok := i.extWidgets[extName]; ok {
3798+ delete(i.extWidgets, extName)
3799+ changed = true
3800+ }
3801+ if len(i.extNotes) > 0 {
3802+ kept := i.extNotes[:0:0]
3803+ for _, line := range i.extNotes {
3804+ if strings.Contains(line, marker) {
3805+ changed = true
3806+ continue
3807+ }
3808+ kept = append(kept, line)
3809+ }
3810+ i.extNotes = kept
3811+ }
3812+ if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
3813+ i.extPanel.Close()
3814+ changed = true
3815+ }
3816+ i.mu.Unlock()
3817+ if changed {
3818+ i.invalidate()
3819+ }
3820+}
3821+
3736 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {3822 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3737 i.mu.Lock()3823 i.mu.Lock()
3738 defer i.mu.Unlock()3824 defer i.mu.Unlock()
M packages/agent/rpc.go +8 -0
@@ -200,6 +200,14 @@ func (h *rpcExtHooks) ClearWidget(extName, id string) {
200200 }
201201 }
202202
203+func (h *rpcExtHooks) ClearExtensionChrome(extName string) {
204+ if h.server != nil {
205+ h.server.writeExtensionEvent(map[string]any{
206+ "type": "ext_chrome_clear", "extension": extName,
207+ })
208+ }
209+}
210+
203211 const maxPendingRPCExtEvents = 64
204212
205213 type rpcServer struct {
@@ -200,6 +200,14 @@ func (h *rpcExtHooks) ClearWidget(extName, id string) {
200 }200 }
201 }201 }
202 202
203+func (h *rpcExtHooks) ClearExtensionChrome(extName string) {
204+ if h.server != nil {
205+ h.server.writeExtensionEvent(map[string]any{
206+ "type": "ext_chrome_clear", "extension": extName,
207+ })
208+ }
209+}
210+
203 const maxPendingRPCExtEvents = 64211 const maxPendingRPCExtEvents = 64
204 212
205 type rpcServer struct {213 type rpcServer struct {
A packages/tui/rightbar.go +239 -0
@@ -0,0 +1,239 @@
1+package tui
2+
3+import (
4+ "sort"
5+ "strings"
6+)
7+
8+const (
9+ // RightBarSeparatorWidth is the one-cell divider between the main pane
10+ // and the host-owned right bar.
11+ RightBarSeparatorWidth = 1
12+ // RightBarMinWidth keeps widget titles and checklist markers readable.
13+ RightBarMinWidth = 24
14+ // RightBarMaxWidth prevents a long extension line from taking most of
15+ // the transcript on wide terminals.
16+ RightBarMaxWidth = 36
17+ // RightBarMinMainWidth preserves enough room for the editor and chat.
18+ RightBarMinMainWidth = 48
19+)
20+
21+// RightBarWidget is declarative content for one persistent extension widget.
22+// The host owns ordering and layout; widgets are display-only in the first
23+// version and should use OpenPanel for interaction.
24+type RightBarWidget struct {
25+ Extension string
26+ ID string
27+ Title string
28+ Lines []string
29+}
30+
31+// RightBarColumns returns the main-pane and side-rail widths for a terminal.
32+// It returns ok=false when keeping both panes usable would leave too little
33+// room for the rail; callers should then render right-bar widgets using their
34+// normal fallback placement.
35+func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
36+ if cols <= 0 {
37+ return cols, 0, false
38+ }
39+ available := cols - RightBarSeparatorWidth
40+ if available <= RightBarMinMainWidth {
41+ return cols, 0, false
42+ }
43+
44+ rightBarWidth = cols / 3
45+ if rightBarWidth < RightBarMinWidth {
46+ rightBarWidth = RightBarMinWidth
47+ }
48+ if rightBarWidth > RightBarMaxWidth {
49+ rightBarWidth = RightBarMaxWidth
50+ }
51+ if available-rightBarWidth < RightBarMinMainWidth {
52+ rightBarWidth = available - RightBarMinMainWidth
53+ }
54+ if rightBarWidth < RightBarMinWidth {
55+ return cols, 0, false
56+ }
57+ return available - rightBarWidth, rightBarWidth, true
58+}
59+
60+// RenderRightBar renders a bounded, full-height side rail. Widgets are sorted
61+// by extension name and ID so output stays deterministic even when extension
62+// frames arrive concurrently. Content that does not fit is replaced by a
63+// compact marker rather than overflowing the terminal or growing the frame.
64+func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
65+ if width <= 0 || height <= 0 {
66+ return nil
67+ }
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+
86+ ordered := append([]RightBarWidget(nil), widgets...)
87+ sort.SliceStable(ordered, func(a, b int) bool {
88+ if ordered[a].Extension != ordered[b].Extension {
89+ return ordered[a].Extension < ordered[b].Extension
90+ }
91+ return ordered[a].ID < ordered[b].ID
92+ })
93+
94+ innerWidth := width - 2
95+ contentRows := height - 2
96+ content := make([]string, 0, contentRows)
97+ truncated := false
98+ appendContent := func(text string, color int) bool {
99+ if len(content) >= contentRows {
100+ truncated = true
101+ return false
102+ }
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)
107+ }
108+ if color >= 0 {
109+ text = th.FG256(color, text)
110+ }
111+ content = append(content, text)
112+ return true
113+ }
114+
115+ for idx, widget := range ordered {
116+ if idx > 0 && !appendContent("", -1) {
117+ break
118+ }
119+ label := "[" + widget.Extension + "]"
120+ if widget.Title != "" {
121+ label += " " + widget.Title
122+ }
123+ if !appendContent(label, th.Accent) {
124+ break
125+ }
126+ for _, line := range widget.Lines {
127+ if !appendContent(" "+line, th.FG) {
128+ break
129+ }
130+ }
131+ if truncated {
132+ break
133+ }
134+ }
135+ if truncated {
136+ marker := "... right bar ..."
137+ marker = truncateToWidth(marker, innerWidth)
138+ if len(content) == contentRows {
139+ content[len(content)-1] = ""
140+ }
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)
146+ }
147+ }
148+
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("│"))
154+ }
155+ for len(out) < height-1 {
156+ out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│"))
157+ }
158+ out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘"))
159+ return out
160+}
161+
162+// JoinRightBar combines one main-pane row and one right-bar row without
163+// allowing either side to soft-wrap. The returned row occupies exactly
164+// mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.
165+func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {
166+ if mainWidth < 1 {
167+ mainWidth = 1
168+ }
169+ if rightBarWidth < 1 {
170+ rightBarWidth = 1
171+ }
172+ main = truncateToWidth(main, mainWidth)
173+ if visible := visibleWidth(main); visible < mainWidth {
174+ main += strings.Repeat(" ", mainWidth-visible)
175+ }
176+ rightBar = truncateToWidth(rightBar, rightBarWidth)
177+ if visible := visibleWidth(rightBar); visible < rightBarWidth {
178+ rightBar += strings.Repeat(" ", rightBarWidth-visible)
179+ }
180+ return main + th.FG256(th.Muted, "│") + rightBar
181+}
182+
183+// DrawRightBar renders an already-laid-out visible chat slice and sticky
184+// bottom block in a fixed frame with a persistent right bar. The normal
185+// DrawLog path deliberately uses terminal scrollback; a persistent side rail
186+// needs a fixed viewport so updates can repaint its columns without leaving
187+// stale rows behind. Interactive mode still owns chat scrolling and passes
188+// the selected visible slice here.
189+func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
190+ if r.cols == 0 || r.rows == 0 {
191+ return
192+ }
193+ mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)
194+ if !ok {
195+ return
196+ }
197+ if len(bottom) == 0 {
198+ bottom = []string{""}
199+ }
200+ if len(rightBar) < r.rows {
201+ padded := make([]string, r.rows)
202+ copy(padded, rightBar)
203+ rightBar = padded
204+ } else if len(rightBar) > r.rows {
205+ rightBar = rightBar[:r.rows]
206+ }
207+
208+ main := make([]string, 0, len(chat)+len(bottom)+1)
209+ main = append(main, chat...)
210+ main = append(main, bottom...)
211+ main = append(main, "") // mirror DrawLog's renderer-owned bottom margin
212+ cursorRow := len(chat) + cursorBottomRow
213+ if len(main) > r.rows {
214+ trim := len(main) - r.rows
215+ main = main[trim:]
216+ cursorRow -= trim
217+ }
218+ if len(main) < r.rows {
219+ padding := make([]string, r.rows-len(main))
220+ main = append(padding, main...)
221+ cursorRow += len(padding)
222+ }
223+
224+ frame := make([]string, r.rows)
225+ for row := range frame {
226+ frame[row] = JoinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth)
227+ }
228+
229+ // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
230+ // later fallback to DrawLog starts with a full repaint instead of trying
231+ // to diff against logical rows from before the rail was enabled.
232+ r.logChat = nil
233+ r.logBottom = nil
234+ r.logLines = nil
235+ r.logViewportTop = 0
236+ r.logHardwareRow = 0
237+ r.logInit = false
238+ r.Draw(frame, cursorRow, cursorCol)
239+}
@@ -0,0 +1,239 @@
1+package tui
2+
3+import (
4+ "sort"
5+ "strings"
6+)
7+
8+const (
9+ // RightBarSeparatorWidth is the one-cell divider between the main pane
10+ // and the host-owned right bar.
11+ RightBarSeparatorWidth = 1
12+ // RightBarMinWidth keeps widget titles and checklist markers readable.
13+ RightBarMinWidth = 24
14+ // RightBarMaxWidth prevents a long extension line from taking most of
15+ // the transcript on wide terminals.
16+ RightBarMaxWidth = 36
17+ // RightBarMinMainWidth preserves enough room for the editor and chat.
18+ RightBarMinMainWidth = 48
19+)
20+
21+// RightBarWidget is declarative content for one persistent extension widget.
22+// The host owns ordering and layout; widgets are display-only in the first
23+// version and should use OpenPanel for interaction.
24+type RightBarWidget struct {
25+ Extension string
26+ ID string
27+ Title string
28+ Lines []string
29+}
30+
31+// RightBarColumns returns the main-pane and side-rail widths for a terminal.
32+// It returns ok=false when keeping both panes usable would leave too little
33+// room for the rail; callers should then render right-bar widgets using their
34+// normal fallback placement.
35+func RightBarColumns(cols int) (mainWidth, rightBarWidth int, ok bool) {
36+ if cols <= 0 {
37+ return cols, 0, false
38+ }
39+ available := cols - RightBarSeparatorWidth
40+ if available <= RightBarMinMainWidth {
41+ return cols, 0, false
42+ }
43+
44+ rightBarWidth = cols / 3
45+ if rightBarWidth < RightBarMinWidth {
46+ rightBarWidth = RightBarMinWidth
47+ }
48+ if rightBarWidth > RightBarMaxWidth {
49+ rightBarWidth = RightBarMaxWidth
50+ }
51+ if available-rightBarWidth < RightBarMinMainWidth {
52+ rightBarWidth = available - RightBarMinMainWidth
53+ }
54+ if rightBarWidth < RightBarMinWidth {
55+ return cols, 0, false
56+ }
57+ return available - rightBarWidth, rightBarWidth, true
58+}
59+
60+// RenderRightBar renders a bounded, full-height side rail. Widgets are sorted
61+// by extension name and ID so output stays deterministic even when extension
62+// frames arrive concurrently. Content that does not fit is replaced by a
63+// compact marker rather than overflowing the terminal or growing the frame.
64+func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []string {
65+ if width <= 0 || height <= 0 {
66+ return nil
67+ }
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+
86+ ordered := append([]RightBarWidget(nil), widgets...)
87+ sort.SliceStable(ordered, func(a, b int) bool {
88+ if ordered[a].Extension != ordered[b].Extension {
89+ return ordered[a].Extension < ordered[b].Extension
90+ }
91+ return ordered[a].ID < ordered[b].ID
92+ })
93+
94+ innerWidth := width - 2
95+ contentRows := height - 2
96+ content := make([]string, 0, contentRows)
97+ truncated := false
98+ appendContent := func(text string, color int) bool {
99+ if len(content) >= contentRows {
100+ truncated = true
101+ return false
102+ }
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)
107+ }
108+ if color >= 0 {
109+ text = th.FG256(color, text)
110+ }
111+ content = append(content, text)
112+ return true
113+ }
114+
115+ for idx, widget := range ordered {
116+ if idx > 0 && !appendContent("", -1) {
117+ break
118+ }
119+ label := "[" + widget.Extension + "]"
120+ if widget.Title != "" {
121+ label += " " + widget.Title
122+ }
123+ if !appendContent(label, th.Accent) {
124+ break
125+ }
126+ for _, line := range widget.Lines {
127+ if !appendContent(" "+line, th.FG) {
128+ break
129+ }
130+ }
131+ if truncated {
132+ break
133+ }
134+ }
135+ if truncated {
136+ marker := "... right bar ..."
137+ marker = truncateToWidth(marker, innerWidth)
138+ if len(content) == contentRows {
139+ content[len(content)-1] = ""
140+ }
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)
146+ }
147+ }
148+
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("│"))
154+ }
155+ for len(out) < height-1 {
156+ out = append(out, border("│")+strings.Repeat(" ", innerWidth)+border("│"))
157+ }
158+ out = append(out, border("└")+strings.Repeat("─", innerWidth)+border("┘"))
159+ return out
160+}
161+
162+// JoinRightBar combines one main-pane row and one right-bar row without
163+// allowing either side to soft-wrap. The returned row occupies exactly
164+// mainWidth + RightBarSeparatorWidth + rightBarWidth display cells.
165+func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) string {
166+ if mainWidth < 1 {
167+ mainWidth = 1
168+ }
169+ if rightBarWidth < 1 {
170+ rightBarWidth = 1
171+ }
172+ main = truncateToWidth(main, mainWidth)
173+ if visible := visibleWidth(main); visible < mainWidth {
174+ main += strings.Repeat(" ", mainWidth-visible)
175+ }
176+ rightBar = truncateToWidth(rightBar, rightBarWidth)
177+ if visible := visibleWidth(rightBar); visible < rightBarWidth {
178+ rightBar += strings.Repeat(" ", rightBarWidth-visible)
179+ }
180+ return main + th.FG256(th.Muted, "│") + rightBar
181+}
182+
183+// DrawRightBar renders an already-laid-out visible chat slice and sticky
184+// bottom block in a fixed frame with a persistent right bar. The normal
185+// DrawLog path deliberately uses terminal scrollback; a persistent side rail
186+// needs a fixed viewport so updates can repaint its columns without leaving
187+// stale rows behind. Interactive mode still owns chat scrolling and passes
188+// the selected visible slice here.
189+func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
190+ if r.cols == 0 || r.rows == 0 {
191+ return
192+ }
193+ mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)
194+ if !ok {
195+ return
196+ }
197+ if len(bottom) == 0 {
198+ bottom = []string{""}
199+ }
200+ if len(rightBar) < r.rows {
201+ padded := make([]string, r.rows)
202+ copy(padded, rightBar)
203+ rightBar = padded
204+ } else if len(rightBar) > r.rows {
205+ rightBar = rightBar[:r.rows]
206+ }
207+
208+ main := make([]string, 0, len(chat)+len(bottom)+1)
209+ main = append(main, chat...)
210+ main = append(main, bottom...)
211+ main = append(main, "") // mirror DrawLog's renderer-owned bottom margin
212+ cursorRow := len(chat) + cursorBottomRow
213+ if len(main) > r.rows {
214+ trim := len(main) - r.rows
215+ main = main[trim:]
216+ cursorRow -= trim
217+ }
218+ if len(main) < r.rows {
219+ padding := make([]string, r.rows-len(main))
220+ main = append(padding, main...)
221+ cursorRow += len(padding)
222+ }
223+
224+ frame := make([]string, r.rows)
225+ for row := range frame {
226+ frame[row] = JoinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth)
227+ }
228+
229+ // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
230+ // later fallback to DrawLog starts with a full repaint instead of trying
231+ // to diff against logical rows from before the rail was enabled.
232+ r.logChat = nil
233+ r.logBottom = nil
234+ r.logLines = nil
235+ r.logViewportTop = 0
236+ r.logHardwareRow = 0
237+ r.logInit = false
238+ r.Draw(frame, cursorRow, cursorCol)
239+}
A packages/tui/rightbar_test.go +107 -0
@@ -0,0 +1,107 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestRightBarColumnsFallsBackWhenMainPaneWouldBeTooNarrow(t *testing.T) {
10+ if _, _, ok := RightBarColumns(72); ok {
11+ t.Fatal("right bar should fall back at 72 columns")
12+ }
13+ main, rail, ok := RightBarColumns(73)
14+ if !ok {
15+ t.Fatal("right bar should fit at 73 columns")
16+ }
17+ if main < RightBarMinMainWidth || rail < RightBarMinWidth || rail > RightBarMaxWidth {
18+ t.Fatalf("right bar columns = main %d, rail %d", main, rail)
19+ }
20+ if main+RightBarSeparatorWidth+rail != 73 {
21+ t.Fatalf("column budget = %d, want 73", main+RightBarSeparatorWidth+rail)
22+ }
23+}
24+
25+func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
26+ lines := RenderRightBar(Dark, []RightBarWidget{
27+ {Extension: "zeta", ID: "second", Title: "Z", Lines: []string{"z"}},
28+ {Extension: "alpha", ID: "first", Title: "A", Lines: []string{"a"}},
29+ }, 24, 7)
30+ if len(lines) != 7 {
31+ t.Fatalf("right bar rows = %d, want 7", len(lines))
32+ }
33+ for idx, line := range lines {
34+ if width := visibleWidth(line); width > 24 {
35+ t.Fatalf("right bar line %d width = %d, want <= 24: %q", idx, width, line)
36+ }
37+ }
38+ joined := stripANSI(strings.Join(lines, "\n"))
39+ if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {
40+ t.Fatalf("widgets are not deterministic by extension: %q", joined)
41+ }
42+
43+ truncated := RenderRightBar(Dark, []RightBarWidget{
44+ {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f"}},
45+ }, 24, 7)
46+ if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {
47+ t.Fatalf("truncation marker missing: %q", truncated)
48+ }
49+}
50+
51+func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
52+ line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
53+ if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {
54+ t.Fatalf("joined width = %d, want %d: %q", got, want, line)
55+ }
56+}
57+
58+func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
59+ var out bytes.Buffer
60+ r := NewRenderer(&out)
61+ r.SetTheme(Dark)
62+ r.Resize(80, 5)
63+ r.DrawRightBar(
64+ []string{"chat"},
65+ []string{"input"},
66+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Old", Lines: []string{"first"}}}, 26, 5),
67+ 0,
68+ 0,
69+ )
70+ if !strings.Contains(stripANSI(out.String()), "Old") {
71+ t.Fatalf("initial right-bar frame omitted widget: %q", out.String())
72+ }
73+
74+ out.Reset()
75+ r.DrawRightBar(
76+ []string{"chat"},
77+ []string{"input"},
78+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Updated", Lines: []string{"second"}}}, 26, 5),
79+ 0,
80+ 0,
81+ )
82+ if !strings.Contains(stripANSI(out.String()), "Updated") {
83+ t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
84+ }
85+ if r.logInit {
86+ t.Fatal("right-bar mode left flow renderer initialized")
87+ }
88+
89+ out.Reset()
90+ r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
91+ if !r.logInit {
92+ t.Fatal("flow renderer was not reinitialized after right-bar clearing")
93+ }
94+
95+ out.Reset()
96+ r.Resize(100, 6)
97+ if out.Len() == 0 {
98+ t.Fatal("resize did not invalidate the right-bar frame")
99+ }
100+ main, rail, ok := RightBarColumns(100)
101+ if !ok {
102+ t.Fatal("right bar should fit after resize")
103+ }
104+ if main+RightBarSeparatorWidth+rail != 100 {
105+ t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
106+ }
107+}
@@ -0,0 +1,107 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestRightBarColumnsFallsBackWhenMainPaneWouldBeTooNarrow(t *testing.T) {
10+ if _, _, ok := RightBarColumns(72); ok {
11+ t.Fatal("right bar should fall back at 72 columns")
12+ }
13+ main, rail, ok := RightBarColumns(73)
14+ if !ok {
15+ t.Fatal("right bar should fit at 73 columns")
16+ }
17+ if main < RightBarMinMainWidth || rail < RightBarMinWidth || rail > RightBarMaxWidth {
18+ t.Fatalf("right bar columns = main %d, rail %d", main, rail)
19+ }
20+ if main+RightBarSeparatorWidth+rail != 73 {
21+ t.Fatalf("column budget = %d, want 73", main+RightBarSeparatorWidth+rail)
22+ }
23+}
24+
25+func TestRenderRightBarSortsWidgetsAndBoundsRows(t *testing.T) {
26+ lines := RenderRightBar(Dark, []RightBarWidget{
27+ {Extension: "zeta", ID: "second", Title: "Z", Lines: []string{"z"}},
28+ {Extension: "alpha", ID: "first", Title: "A", Lines: []string{"a"}},
29+ }, 24, 7)
30+ if len(lines) != 7 {
31+ t.Fatalf("right bar rows = %d, want 7", len(lines))
32+ }
33+ for idx, line := range lines {
34+ if width := visibleWidth(line); width > 24 {
35+ t.Fatalf("right bar line %d width = %d, want <= 24: %q", idx, width, line)
36+ }
37+ }
38+ joined := stripANSI(strings.Join(lines, "\n"))
39+ if strings.Index(joined, "[alpha] A") > strings.Index(joined, "[zeta] Z") {
40+ t.Fatalf("widgets are not deterministic by extension: %q", joined)
41+ }
42+
43+ truncated := RenderRightBar(Dark, []RightBarWidget{
44+ {Extension: "alpha", ID: "large", Lines: []string{"a", "b", "c", "d", "e", "f"}},
45+ }, 24, 7)
46+ if !strings.Contains(stripANSI(strings.Join(truncated, "\n")), "right bar") {
47+ t.Fatalf("truncation marker missing: %q", truncated)
48+ }
49+}
50+
51+func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
52+ line := JoinRightBar(Dark, "界", "✓ done", 8, 6)
53+ if got, want := visibleWidth(line), 8+RightBarSeparatorWidth+6; got != want {
54+ t.Fatalf("joined width = %d, want %d: %q", got, want, line)
55+ }
56+}
57+
58+func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
59+ var out bytes.Buffer
60+ r := NewRenderer(&out)
61+ r.SetTheme(Dark)
62+ r.Resize(80, 5)
63+ r.DrawRightBar(
64+ []string{"chat"},
65+ []string{"input"},
66+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Old", Lines: []string{"first"}}}, 26, 5),
67+ 0,
68+ 0,
69+ )
70+ if !strings.Contains(stripANSI(out.String()), "Old") {
71+ t.Fatalf("initial right-bar frame omitted widget: %q", out.String())
72+ }
73+
74+ out.Reset()
75+ r.DrawRightBar(
76+ []string{"chat"},
77+ []string{"input"},
78+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Updated", Lines: []string{"second"}}}, 26, 5),
79+ 0,
80+ 0,
81+ )
82+ if !strings.Contains(stripANSI(out.String()), "Updated") {
83+ t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
84+ }
85+ if r.logInit {
86+ t.Fatal("right-bar mode left flow renderer initialized")
87+ }
88+
89+ out.Reset()
90+ r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
91+ if !r.logInit {
92+ t.Fatal("flow renderer was not reinitialized after right-bar clearing")
93+ }
94+
95+ out.Reset()
96+ r.Resize(100, 6)
97+ if out.Len() == 0 {
98+ t.Fatal("resize did not invalidate the right-bar frame")
99+ }
100+ main, rail, ok := RightBarColumns(100)
101+ if !ok {
102+ t.Fatal("right bar should fit after resize")
103+ }
104+ if main+RightBarSeparatorWidth+rail != 100 {
105+ t.Fatalf("resized column budget = %d", main+RightBarSeparatorWidth+rail)
106+ }
107+}

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