detached ddc3e5b

Merge pull request #26 from bnema/feat/issue-17-right-bar

feat(agent): add extension right-bar widgets
commit
ddc3e5b531182c6bc40f2aeb8704ea30cf532c10
author
bnema · · 2026-08-05 09:08 UTC
committer
GitHub · · 2026-08-05 09:08 UTC
merge of
9d5e54e + 44c56dd · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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
@@ -1235,7 +1235,30 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
12351235 const maxExtensionWidgetRows = 12
12361236 const maxExtensionStatusRows = 6
12371237
1238+func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1239+ var out []tui.RightBarWidget
1240+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1241+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1242+ widget := i.extWidgets[extName][id]
1243+ if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
1244+ continue
1245+ }
1246+ out = append(out, tui.RightBarWidget{
1247+ Extension: extName,
1248+ ID: id,
1249+ Title: widget.Title,
1250+ Lines: append([]string(nil), widget.Lines...),
1251+ })
1252+ }
1253+ }
1254+ return out
1255+}
1256+
12381257 func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1258+ return i.extensionChromeLinesAtLocked(cols, false)
1259+}
1260+
1261+func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
12391262 var out []string
12401263 bodyWidth := cols - 2
12411264 if bodyWidth < 1 {
@@ -1260,6 +1283,9 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
12601283 }
12611284 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
12621285 widget := i.extWidgets[extName][id]
1286+ if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
1287+ continue
1288+ }
12631289 label := " [" + extName + "]"
12641290 if widget.Title != "" {
12651291 label += " " + widget.Title
@@ -1560,20 +1586,31 @@ func (i *Interactive) redraw() {
15601586 i.mu.Lock()
15611587 defer i.mu.Unlock()
15621588
1563- cols, _ := i.cfg.Terminal.Size()
1564- chat := i.cachedChatLocked(cols)
1589+ cols, rows := i.cfg.Terminal.Size()
1590+ mainCols := cols
1591+ rightBarWidgets := i.rightBarWidgetsLocked()
1592+ rightBarActive := false
1593+ var rightBarWidth int
1594+ if len(rightBarWidgets) > 0 {
1595+ if width, rail, ok := tui.RightBarColumns(cols); ok {
1596+ mainCols = width
1597+ rightBarWidth = rail
1598+ rightBarActive = true
1599+ }
1600+ }
1601+ chat := i.cachedChatLocked(mainCols)
15651602
15661603 // Dialogs (login or model picker) render between chat and the editor.
15671604 var dialog []string
15681605 switch {
15691606 case i.dialog.Active():
1570- dialog = i.dialog.Render(i.cfg.Theme, cols)
1607+ dialog = i.dialog.Render(i.cfg.Theme, mainCols)
15711608 case i.modelDialog.Active():
1572- dialog = i.modelDialog.Render(i.cfg.Theme, cols)
1609+ dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
15731610 case i.llamaDialog.Active():
1574- dialog = i.llamaDialog.Render(i.cfg.Theme, cols)
1611+ dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
15751612 case i.rescueDialog.Active():
1576- dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
1613+ dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
15771614 case i.sessionDialog.Active():
15781615 // Reserve rows for the editor (~3), status line (1-2),
15791616 // dialog chrome (header + hint + rule + indicators, ~5),
@@ -1585,36 +1622,36 @@ func (i *Interactive) redraw() {
15851622 avail = 3
15861623 }
15871624 i.sessionDialog.MaxRows = avail
1588- dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
1625+ dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
15891626 case i.subagentsDialog.Active():
1590- dialog = i.subagentsDialog.Render(i.cfg.Theme, cols)
1627+ dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
15911628 case i.jumpDialog.Active():
1592- dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
1629+ dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
15931630 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1594- dialog = i.extPanel.Render(i.cfg.Theme, cols)
1631+ dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
15951632 case i.confirmDialog.Active():
15961633 if i.btwDialog.Active() {
15971634 // Keep the side-chat transcript and its live tool preview visible
15981635 // above confirmation, matching how the main transcript remains
15991636 // visible while --no-yolo waits for a decision.
1600- dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)
1637+ dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
16011638 } else {
1602- dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
1639+ dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
16031640 }
16041641 case i.btwDialog.Active():
1605- dialog = i.btwDialog.Render(i.cfg.Theme, cols)
1642+ dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
16061643 case i.skillsDialog.Active():
1607- dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
1644+ dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
16081645 case i.changelogDialog.Active():
1609- dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
1646+ dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
16101647 case i.logoutDialog.Active():
1611- dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
1648+ dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
16121649 case i.telegramDialog.Active():
1613- dialog = i.telegramDialog.Render(i.cfg.Theme, cols)
1650+ dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
16141651 case i.settingsDialog.Active():
1615- dialog = i.settingsDialog.Render(i.cfg.Theme, cols)
1652+ dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
16161653 case i.sessionOpsDialog.Active():
1617- dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)
1654+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
16181655 case i.sessionTreeDialog.Active():
16191656 // Reserve rows for the editor, status, and tree chrome while using
16201657 // the same budget for rendering and PageUp/PageDown movement.
@@ -1624,7 +1661,7 @@ func (i *Interactive) redraw() {
16241661 avail = 3
16251662 }
16261663 i.sessionTreeDialog.MaxRows = avail
1627- dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)
1664+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
16281665 }
16291666 if len(dialog) > 0 {
16301667 dialog = padDialogFrame(dialog)
@@ -1673,9 +1710,9 @@ func (i *Interactive) redraw() {
16731710 i.fileSuggest.SetCWD(i.cfg.CWD)
16741711 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
16751712 if mainInputFocused && i.suggest.Active(currentInput) {
1676- suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)
1713+ suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
16771714 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
1678- suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)
1715+ suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
16791716 }
16801717
16811718 // Detect overlay close (any dialog or slash/file suggestion popup
@@ -1749,7 +1786,7 @@ func (i *Interactive) redraw() {
17491786 ContextMax: ctxMax,
17501787 AutoCompacting: i.autoCompacting,
17511788 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1752- Cols: cols,
1789+ Cols: mainCols,
17531790 })
17541791 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
17551792 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
@@ -1757,7 +1794,7 @@ func (i *Interactive) redraw() {
17571794 } else {
17581795 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
17591796 }
1760- edLines, curR, curC := i.ed.Render(cols)
1797+ edLines, curR, curC := i.ed.Render(mainCols)
17611798 var workingLines []string
17621799 if busyPrefix != "" && !workingWithStatus {
17631800 workingLines = []string{" " + busyPrefix}
@@ -1766,10 +1803,10 @@ func (i *Interactive) redraw() {
17661803 inputCursorColOffset := 0
17671804 switch inputStyle {
17681805 case tui.InputStyleLines:
1769- edLines = tui.InputLines(i.cfg.Theme, edLines, cols)
1806+ edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
17701807 inputCursorOffset = 1
17711808 case tui.InputStyleBlock:
1772- edLines = tui.InputBlock(i.cfg.Theme, edLines, cols)
1809+ edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
17731810 inputCursorOffset = 1
17741811 inputCursorColOffset = 2
17751812 }
@@ -1786,7 +1823,7 @@ func (i *Interactive) redraw() {
17861823 queue = append(queue, "")
17871824 for _, q := range queued {
17881825 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1789- text := truncateLine(q, cols-17)
1826+ text := truncateLine(q, mainCols-17)
17901827 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
17911828 }
17921829 // Hint row, rendered in the same muted tone as the model
@@ -1797,7 +1834,7 @@ func (i *Interactive) redraw() {
17971834 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
17981835 }
17991836
1800- extensionLines := i.extensionChromeLinesLocked(cols)
1837+ extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive)
18011838
18021839 // Bottom-sticky sections (always visible, never scroll). Each
18031840 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -1861,8 +1898,12 @@ func (i *Interactive) redraw() {
18611898 }
18621899 }
18631900
1864- _, rows := i.cfg.Terminal.Size()
18651901 chatRows := rows - len(bottom)
1902+ if rightBarActive {
1903+ // DrawRightBar keeps the renderer-owned bottom margin inside the
1904+ // fixed frame, so reserve that row before selecting visible chat.
1905+ chatRows--
1906+ }
18661907 if chatRows < 1 {
18671908 chatRows = 1
18681909 }
@@ -1890,12 +1931,12 @@ func (i *Interactive) redraw() {
18901931 // let a shrinking chatRows pull the window toward the tail, which
18911932 // read as the viewport jumping to the bottom whenever the agent
18921933 // streamed text or a tool call grew the bottom band.
1893- if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {
1934+ if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
18941935 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
18951936 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
18961937 }
18971938 i.prevChatLen = len(chat)
1898- i.prevChatCols = cols
1939+ i.prevChatCols = mainCols
18991940 i.prevChatRows = chatRows
19001941
19011942 // Apply scroll offset to the chat slice.
@@ -1917,7 +1958,7 @@ func (i *Interactive) redraw() {
19171958 i.view.TailLimit = 0 // unbounded
19181959 }
19191960 i.chatCacheValid = false
1920- chat = i.cachedChatLocked(cols)
1961+ chat = i.cachedChatLocked(mainCols)
19211962 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
19221963 chat = chat[:len(chat)-1]
19231964 }
@@ -1933,7 +1974,7 @@ func (i *Interactive) redraw() {
19331974 i.scrollOffset += grew
19341975 }
19351976 i.prevChatLen = len(chat)
1936- i.prevChatCols = cols
1977+ i.prevChatCols = mainCols
19371978 maxOffset = len(chat) - chatRows
19381979 if maxOffset < 0 {
19391980 maxOffset = 0
@@ -2018,13 +2059,13 @@ func (i *Interactive) redraw() {
20182059 cursorRow := inputStartRow + inputCursorOffset + curR
20192060 cursorCol := curC + inputCursorColOffset
20202061 if i.btwDialog.Active() {
2021- if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
2062+ if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
20222063 cursorRow = dialogLead + r
20232064 cursorCol = c
20242065 }
20252066 }
20262067 if i.dialog.Active() {
2027- if r, c := i.dialog.CursorPos(cols); r >= 0 {
2068+ if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
20282069 cursorRow = dialogLead + r
20292070 cursorCol = c
20302071 }
@@ -2051,7 +2092,7 @@ func (i *Interactive) redraw() {
20512092 }
20522093 }
20532094 if i.subagentsDialog.Active() {
2054- if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 {
2095+ if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
20552096 cursorRow = dialogLead + r
20562097 cursorCol = c
20572098 } else {
@@ -2067,8 +2108,13 @@ func (i *Interactive) redraw() {
20672108 cursorRow = -1
20682109 cursorCol = 0
20692110 }
2070- _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2071- i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2111+ if rightBarActive {
2112+ rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2113+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2114+ } else {
2115+ _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2116+ i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2117+ }
20722118 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
20732119 alert := *i.pendingAlert
20742120 i.pendingAlert = nil
@@ -3748,6 +3794,7 @@ func (i *Interactive) SetWidget(extName, id, position, title string, lines []str
37483794 if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
37493795 return
37503796 }
3797+ position = extproto.NormalizeWidgetPosition(position)
37513798 i.mu.Lock()
37523799 if i.extWidgets == nil {
37533800 i.extWidgets = map[string]map[string]extensionWidget{}
@@ -3777,6 +3824,45 @@ func (i *Interactive) ClearWidget(extName, id string) {
37773824 i.invalidate()
37783825 }
37793826
3827+// ClearExtensionChrome removes every persistent UI item owned by an
3828+// extension that has exited. This keeps crashes, disable/reload operations,
3829+// and startup failures from leaving stale widgets beside the transcript.
3830+func (i *Interactive) ClearExtensionChrome(extName string) {
3831+ if strings.TrimSpace(extName) == "" {
3832+ return
3833+ }
3834+ marker := "[" + extName + "] "
3835+ i.mu.Lock()
3836+ changed := false
3837+ if _, ok := i.extStatuses[extName]; ok {
3838+ delete(i.extStatuses, extName)
3839+ changed = true
3840+ }
3841+ if _, ok := i.extWidgets[extName]; ok {
3842+ delete(i.extWidgets, extName)
3843+ changed = true
3844+ }
3845+ if len(i.extNotes) > 0 {
3846+ kept := i.extNotes[:0:0]
3847+ for _, line := range i.extNotes {
3848+ if strings.Contains(line, marker) {
3849+ changed = true
3850+ continue
3851+ }
3852+ kept = append(kept, line)
3853+ }
3854+ i.extNotes = kept
3855+ }
3856+ if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
3857+ i.extPanel.Close()
3858+ changed = true
3859+ }
3860+ i.mu.Unlock()
3861+ if changed {
3862+ i.invalidate()
3863+ }
3864+}
3865+
37803866 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
37813867 i.mu.Lock()
37823868 defer i.mu.Unlock()
@@ -1235,7 +1235,30 @@ func (i *Interactive) extensionWidgetsKeyLocked() string {
1235 const maxExtensionWidgetRows = 121235 const maxExtensionWidgetRows = 12
1236 const maxExtensionStatusRows = 61236 const maxExtensionStatusRows = 6
1237 1237
1238+func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1239+ var out []tui.RightBarWidget
1240+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1241+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1242+ widget := i.extWidgets[extName][id]
1243+ if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
1244+ continue
1245+ }
1246+ out = append(out, tui.RightBarWidget{
1247+ Extension: extName,
1248+ ID: id,
1249+ Title: widget.Title,
1250+ Lines: append([]string(nil), widget.Lines...),
1251+ })
1252+ }
1253+ }
1254+ return out
1255+}
1256+
1238 func (i *Interactive) extensionChromeLinesLocked(cols int) []string {1257 func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1258+ return i.extensionChromeLinesAtLocked(cols, false)
1259+}
1260+
1261+func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
1239 var out []string1262 var out []string
1240 bodyWidth := cols - 21263 bodyWidth := cols - 2
1241 if bodyWidth < 1 {1264 if bodyWidth < 1 {
@@ -1260,6 +1283,9 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1260 }1283 }
1261 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {1284 for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1262 widget := i.extWidgets[extName][id]1285 widget := i.extWidgets[extName][id]
1286+ if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
1287+ continue
1288+ }
1263 label := " [" + extName + "]"1289 label := " [" + extName + "]"
1264 if widget.Title != "" {1290 if widget.Title != "" {
1265 label += " " + widget.Title1291 label += " " + widget.Title
@@ -1560,20 +1586,31 @@ func (i *Interactive) redraw() {
1560 i.mu.Lock()1586 i.mu.Lock()
1561 defer i.mu.Unlock()1587 defer i.mu.Unlock()
1562 1588
1563- cols, _ := i.cfg.Terminal.Size()1589+ cols, rows := i.cfg.Terminal.Size()
1564- chat := i.cachedChatLocked(cols)1590+ mainCols := cols
1591+ rightBarWidgets := i.rightBarWidgetsLocked()
1592+ rightBarActive := false
1593+ var rightBarWidth int
1594+ if len(rightBarWidgets) > 0 {
1595+ if width, rail, ok := tui.RightBarColumns(cols); ok {
1596+ mainCols = width
1597+ rightBarWidth = rail
1598+ rightBarActive = true
1599+ }
1600+ }
1601+ chat := i.cachedChatLocked(mainCols)
1565 1602
1566 // Dialogs (login or model picker) render between chat and the editor.1603 // Dialogs (login or model picker) render between chat and the editor.
1567 var dialog []string1604 var dialog []string
1568 switch {1605 switch {
1569 case i.dialog.Active():1606 case i.dialog.Active():
1570- dialog = i.dialog.Render(i.cfg.Theme, cols)1607+ dialog = i.dialog.Render(i.cfg.Theme, mainCols)
1571 case i.modelDialog.Active():1608 case i.modelDialog.Active():
1572- dialog = i.modelDialog.Render(i.cfg.Theme, cols)1609+ dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
1573 case i.llamaDialog.Active():1610 case i.llamaDialog.Active():
1574- dialog = i.llamaDialog.Render(i.cfg.Theme, cols)1611+ dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
1575 case i.rescueDialog.Active():1612 case i.rescueDialog.Active():
1576- dialog = i.rescueDialog.Render(i.cfg.Theme, cols)1613+ dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
1577 case i.sessionDialog.Active():1614 case i.sessionDialog.Active():
1578 // Reserve rows for the editor (~3), status line (1-2),1615 // Reserve rows for the editor (~3), status line (1-2),
1579 // dialog chrome (header + hint + rule + indicators, ~5),1616 // dialog chrome (header + hint + rule + indicators, ~5),
@@ -1585,36 +1622,36 @@ func (i *Interactive) redraw() {
1585 avail = 31622 avail = 3
1586 }1623 }
1587 i.sessionDialog.MaxRows = avail1624 i.sessionDialog.MaxRows = avail
1588- dialog = i.sessionDialog.Render(i.cfg.Theme, cols)1625+ dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1589 case i.subagentsDialog.Active():1626 case i.subagentsDialog.Active():
1590- dialog = i.subagentsDialog.Render(i.cfg.Theme, cols)1627+ dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols)
1591 case i.jumpDialog.Active():1628 case i.jumpDialog.Active():
1592- dialog = i.jumpDialog.Render(i.cfg.Theme, cols)1629+ dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
1593 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):1630 case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1594- dialog = i.extPanel.Render(i.cfg.Theme, cols)1631+ dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
1595 case i.confirmDialog.Active():1632 case i.confirmDialog.Active():
1596 if i.btwDialog.Active() {1633 if i.btwDialog.Active() {
1597 // Keep the side-chat transcript and its live tool preview visible1634 // Keep the side-chat transcript and its live tool preview visible
1598 // above confirmation, matching how the main transcript remains1635 // above confirmation, matching how the main transcript remains
1599 // visible while --no-yolo waits for a decision.1636 // visible while --no-yolo waits for a decision.
1600- dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)1637+ dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
1601 } else {1638 } else {
1602- dialog = i.confirmDialog.Render(i.cfg.Theme, cols)1639+ dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
1603 }1640 }
1604 case i.btwDialog.Active():1641 case i.btwDialog.Active():
1605- dialog = i.btwDialog.Render(i.cfg.Theme, cols)1642+ dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
1606 case i.skillsDialog.Active():1643 case i.skillsDialog.Active():
1607- dialog = i.skillsDialog.Render(i.cfg.Theme, cols)1644+ dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
1608 case i.changelogDialog.Active():1645 case i.changelogDialog.Active():
1609- dialog = i.changelogDialog.Render(i.cfg.Theme, cols)1646+ dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
1610 case i.logoutDialog.Active():1647 case i.logoutDialog.Active():
1611- dialog = i.logoutDialog.Render(i.cfg.Theme, cols)1648+ dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
1612 case i.telegramDialog.Active():1649 case i.telegramDialog.Active():
1613- dialog = i.telegramDialog.Render(i.cfg.Theme, cols)1650+ dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
1614 case i.settingsDialog.Active():1651 case i.settingsDialog.Active():
1615- dialog = i.settingsDialog.Render(i.cfg.Theme, cols)1652+ dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
1616 case i.sessionOpsDialog.Active():1653 case i.sessionOpsDialog.Active():
1617- dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols)1654+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
1618 case i.sessionTreeDialog.Active():1655 case i.sessionTreeDialog.Active():
1619 // Reserve rows for the editor, status, and tree chrome while using1656 // Reserve rows for the editor, status, and tree chrome while using
1620 // the same budget for rendering and PageUp/PageDown movement.1657 // the same budget for rendering and PageUp/PageDown movement.
@@ -1624,7 +1661,7 @@ func (i *Interactive) redraw() {
1624 avail = 31661 avail = 3
1625 }1662 }
1626 i.sessionTreeDialog.MaxRows = avail1663 i.sessionTreeDialog.MaxRows = avail
1627- dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols)1664+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
1628 }1665 }
1629 if len(dialog) > 0 {1666 if len(dialog) > 0 {
1630 dialog = padDialogFrame(dialog)1667 dialog = padDialogFrame(dialog)
@@ -1673,9 +1710,9 @@ func (i *Interactive) redraw() {
1673 i.fileSuggest.SetCWD(i.cfg.CWD)1710 i.fileSuggest.SetCWD(i.cfg.CWD)
1674 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())1711 mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
1675 if mainInputFocused && i.suggest.Active(currentInput) {1712 if mainInputFocused && i.suggest.Active(currentInput) {
1676- suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols)1713+ suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
1677 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {1714 } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
1678- suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols)1715+ suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
1679 }1716 }
1680 1717
1681 // Detect overlay close (any dialog or slash/file suggestion popup1718 // Detect overlay close (any dialog or slash/file suggestion popup
@@ -1749,7 +1786,7 @@ func (i *Interactive) redraw() {
1749 ContextMax: ctxMax,1786 ContextMax: ctxMax,
1750 AutoCompacting: i.autoCompacting,1787 AutoCompacting: i.autoCompacting,
1751 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),1788 Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
1752- Cols: cols,1789+ Cols: mainCols,
1753 })1790 })
1754 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)1791 inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
1755 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {1792 if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
@@ -1757,7 +1794,7 @@ func (i *Interactive) redraw() {
1757 } else {1794 } else {
1758 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)1795 i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
1759 }1796 }
1760- edLines, curR, curC := i.ed.Render(cols)1797+ edLines, curR, curC := i.ed.Render(mainCols)
1761 var workingLines []string1798 var workingLines []string
1762 if busyPrefix != "" && !workingWithStatus {1799 if busyPrefix != "" && !workingWithStatus {
1763 workingLines = []string{" " + busyPrefix}1800 workingLines = []string{" " + busyPrefix}
@@ -1766,10 +1803,10 @@ func (i *Interactive) redraw() {
1766 inputCursorColOffset := 01803 inputCursorColOffset := 0
1767 switch inputStyle {1804 switch inputStyle {
1768 case tui.InputStyleLines:1805 case tui.InputStyleLines:
1769- edLines = tui.InputLines(i.cfg.Theme, edLines, cols)1806+ edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
1770 inputCursorOffset = 11807 inputCursorOffset = 1
1771 case tui.InputStyleBlock:1808 case tui.InputStyleBlock:
1772- edLines = tui.InputBlock(i.cfg.Theme, edLines, cols)1809+ edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
1773 inputCursorOffset = 11810 inputCursorOffset = 1
1774 inputCursorColOffset = 21811 inputCursorColOffset = 2
1775 }1812 }
@@ -1786,7 +1823,7 @@ func (i *Interactive) redraw() {
1786 queue = append(queue, "")1823 queue = append(queue, "")
1787 for _, q := range queued {1824 for _, q := range queued {
1788 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")1825 label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ")
1789- text := truncateLine(q, cols-17)1826+ text := truncateLine(q, mainCols-17)
1790 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))1827 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
1791 }1828 }
1792 // Hint row, rendered in the same muted tone as the model1829 // Hint row, rendered in the same muted tone as the model
@@ -1797,7 +1834,7 @@ func (i *Interactive) redraw() {
1797 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))1834 queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint))
1798 }1835 }
1799 1836
1800- extensionLines := i.extensionChromeLinesLocked(cols)1837+ extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive)
1801 1838
1802 // Bottom-sticky sections (always visible, never scroll). Each1839 // Bottom-sticky sections (always visible, never scroll). Each
1803 // non-empty subsection (dialog, suggest popup, sliding-in queue)1840 // non-empty subsection (dialog, suggest popup, sliding-in queue)
@@ -1861,8 +1898,12 @@ func (i *Interactive) redraw() {
1861 }1898 }
1862 }1899 }
1863 1900
1864- _, rows := i.cfg.Terminal.Size()
1865 chatRows := rows - len(bottom)1901 chatRows := rows - len(bottom)
1902+ if rightBarActive {
1903+ // DrawRightBar keeps the renderer-owned bottom margin inside the
1904+ // fixed frame, so reserve that row before selecting visible chat.
1905+ chatRows--
1906+ }
1866 if chatRows < 1 {1907 if chatRows < 1 {
1867 chatRows = 11908 chatRows = 1
1868 }1909 }
@@ -1890,12 +1931,12 @@ func (i *Interactive) redraw() {
1890 // let a shrinking chatRows pull the window toward the tail, which1931 // let a shrinking chatRows pull the window toward the tail, which
1891 // read as the viewport jumping to the bottom whenever the agent1932 // read as the viewport jumping to the bottom whenever the agent
1892 // streamed text or a tool call grew the bottom band.1933 // streamed text or a tool call grew the bottom band.
1893- if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 {1934+ if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
1894 i.scrollOffset = anchorScrollOffset(i.scrollOffset,1935 i.scrollOffset = anchorScrollOffset(i.scrollOffset,
1895 i.prevChatLen, len(chat), i.prevChatRows, chatRows)1936 i.prevChatLen, len(chat), i.prevChatRows, chatRows)
1896 }1937 }
1897 i.prevChatLen = len(chat)1938 i.prevChatLen = len(chat)
1898- i.prevChatCols = cols1939+ i.prevChatCols = mainCols
1899 i.prevChatRows = chatRows1940 i.prevChatRows = chatRows
1900 1941
1901 // Apply scroll offset to the chat slice.1942 // Apply scroll offset to the chat slice.
@@ -1917,7 +1958,7 @@ func (i *Interactive) redraw() {
1917 i.view.TailLimit = 0 // unbounded1958 i.view.TailLimit = 0 // unbounded
1918 }1959 }
1919 i.chatCacheValid = false1960 i.chatCacheValid = false
1920- chat = i.cachedChatLocked(cols)1961+ chat = i.cachedChatLocked(mainCols)
1921 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {1962 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1922 chat = chat[:len(chat)-1]1963 chat = chat[:len(chat)-1]
1923 }1964 }
@@ -1933,7 +1974,7 @@ func (i *Interactive) redraw() {
1933 i.scrollOffset += grew1974 i.scrollOffset += grew
1934 }1975 }
1935 i.prevChatLen = len(chat)1976 i.prevChatLen = len(chat)
1936- i.prevChatCols = cols1977+ i.prevChatCols = mainCols
1937 maxOffset = len(chat) - chatRows1978 maxOffset = len(chat) - chatRows
1938 if maxOffset < 0 {1979 if maxOffset < 0 {
1939 maxOffset = 01980 maxOffset = 0
@@ -2018,13 +2059,13 @@ func (i *Interactive) redraw() {
2018 cursorRow := inputStartRow + inputCursorOffset + curR2059 cursorRow := inputStartRow + inputCursorOffset + curR
2019 cursorCol := curC + inputCursorColOffset2060 cursorCol := curC + inputCursorColOffset
2020 if i.btwDialog.Active() {2061 if i.btwDialog.Active() {
2021- if r, c := i.btwDialog.CursorPos(cols); r >= 0 {2062+ if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
2022 cursorRow = dialogLead + r2063 cursorRow = dialogLead + r
2023 cursorCol = c2064 cursorCol = c
2024 }2065 }
2025 }2066 }
2026 if i.dialog.Active() {2067 if i.dialog.Active() {
2027- if r, c := i.dialog.CursorPos(cols); r >= 0 {2068+ if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
2028 cursorRow = dialogLead + r2069 cursorRow = dialogLead + r
2029 cursorCol = c2070 cursorCol = c
2030 }2071 }
@@ -2051,7 +2092,7 @@ func (i *Interactive) redraw() {
2051 }2092 }
2052 }2093 }
2053 if i.subagentsDialog.Active() {2094 if i.subagentsDialog.Active() {
2054- if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 {2095+ if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 {
2055 cursorRow = dialogLead + r2096 cursorRow = dialogLead + r
2056 cursorCol = c2097 cursorCol = c
2057 } else {2098 } else {
@@ -2067,8 +2108,13 @@ func (i *Interactive) redraw() {
2067 cursorRow = -12108 cursorRow = -1
2068 cursorCol = 02109 cursorCol = 0
2069 }2110 }
2070- _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.2111+ if rightBarActive {
2071- i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)2112+ rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows)
2113+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2114+ } else {
2115+ _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2116+ i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2117+ }
2072 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {2118 if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
2073 alert := *i.pendingAlert2119 alert := *i.pendingAlert
2074 i.pendingAlert = nil2120 i.pendingAlert = nil
@@ -3748,6 +3794,7 @@ func (i *Interactive) SetWidget(extName, id, position, title string, lines []str
3748 if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {3794 if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
3749 return3795 return
3750 }3796 }
3797+ position = extproto.NormalizeWidgetPosition(position)
3751 i.mu.Lock()3798 i.mu.Lock()
3752 if i.extWidgets == nil {3799 if i.extWidgets == nil {
3753 i.extWidgets = map[string]map[string]extensionWidget{}3800 i.extWidgets = map[string]map[string]extensionWidget{}
@@ -3777,6 +3824,45 @@ func (i *Interactive) ClearWidget(extName, id string) {
3777 i.invalidate()3824 i.invalidate()
3778 }3825 }
3779 3826
3827+// ClearExtensionChrome removes every persistent UI item owned by an
3828+// extension that has exited. This keeps crashes, disable/reload operations,
3829+// and startup failures from leaving stale widgets beside the transcript.
3830+func (i *Interactive) ClearExtensionChrome(extName string) {
3831+ if strings.TrimSpace(extName) == "" {
3832+ return
3833+ }
3834+ marker := "[" + extName + "] "
3835+ i.mu.Lock()
3836+ changed := false
3837+ if _, ok := i.extStatuses[extName]; ok {
3838+ delete(i.extStatuses, extName)
3839+ changed = true
3840+ }
3841+ if _, ok := i.extWidgets[extName]; ok {
3842+ delete(i.extWidgets, extName)
3843+ changed = true
3844+ }
3845+ if len(i.extNotes) > 0 {
3846+ kept := i.extNotes[:0:0]
3847+ for _, line := range i.extNotes {
3848+ if strings.Contains(line, marker) {
3849+ changed = true
3850+ continue
3851+ }
3852+ kept = append(kept, line)
3853+ }
3854+ i.extNotes = kept
3855+ }
3856+ if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
3857+ i.extPanel.Close()
3858+ changed = true
3859+ }
3860+ i.mu.Unlock()
3861+ if changed {
3862+ i.invalidate()
3863+ }
3864+}
3865+
3780 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {3866 func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
3781 i.mu.Lock()3867 i.mu.Lock()
3782 defer i.mu.Unlock()3868 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