feat(agent): add extension right-bar widgets
- commit
- 44c56dd8aaa438c8e6d4dbf772ff6862d663bdb1
- author
- brice · · 2026-08-05 08:56 UTC
- parent
- 1513f55
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
18 files · +743 -65
M docs/extensions.md +25 -3
| @@ -418,11 +418,22 @@ Sets or replaces one status item owned by the extension. Sending an empty | |||
| 418 | 418 | ||
| 419 | 419 | #### `widget` (one-way, persistent) | |
| 420 | 420 | ||
| 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. | |
| 423 | 434 | ||
| 424 | 435 | ```json | |
| 425 | - | {"type":"widget","id":"plan","position":"above_input", | |
| 436 | + | {"type":"widget","id":"plan","position":"right_bar", | |
| 426 | 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 | 715 | request. `SetStatus`, `SetWidget`, and their clear methods update persistent | |
| 705 | 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 | 729 | The SDK has four interceptor hooks, all optional: | |
| 708 | 730 | ||
| 709 | 731 | ```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 widgets | 421 | + | 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 | ```json | 435 | ```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 persistent | 715 | 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 | ```go | 731 | ```go | ||
M docs/rpc.md +3 -0
| @@ -205,6 +205,9 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`. | |||
| 205 | 205 | | `error` | `message` | Non-fatal error message | | |
| 206 | 206 | | `compact_done` | `summary` | Compaction finished, summary text included | | |
| 207 | 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 | 212 | ## Message shape | |
| 210 | 213 | ||
| @@ -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 shape | 212 | ## 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 | |||
| 62 | 62 | as it is completed, and advance the current phase when work moves forward. | |
| 63 | 63 | Completed phases and future phases are not repeated in every provider request; | |
| 64 | 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. | |
| @@ -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 { | |||
| 202 | 202 | ||
| 203 | 203 | func (a *app) publishUnavailableChrome() { | |
| 204 | 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 | 206 | a.panelMu.Lock() | |
| 207 | 207 | open := a.panelOpen | |
| 208 | 208 | a.panelMu.Unlock() | |
| @@ -251,7 +251,7 @@ func applyChrome(host chromeHost, state PlanState) { | |||
| 251 | 251 | return | |
| 252 | 252 | } | |
| 253 | 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 | 257 | 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.panelOpen | 207 | open := a.panelOpen | ||
| 208 | a.panelMu.Unlock() | 208 | a.panelMu.Unlock() | ||
| @@ -251,7 +251,7 @@ func applyChrome(host chromeHost, state PlanState) { | |||||
| 251 | return | 251 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestBuildChromeHidesEmptyPlan(t *testing.T) { | |
| @@ -42,19 +44,38 @@ func TestBuildChromeShowsStoredPlan(t *testing.T) { | |||
| 42 | 44 | } | |
| 43 | 45 | ||
| 44 | 46 | 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 | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 51 | 54 | func (r *chromeRecorder) SetStatus(string, string) { r.statusSet++ } | |
| 52 | 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 | 57 | r.widgetSet++ | |
| 58 | + | r.widgetPositions = append(r.widgetPositions, position) | |
| 55 | 59 | } | |
| 56 | 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 | 79 | func TestApplyChromeClearsEmptyPlan(t *testing.T) { | |
| 59 | 80 | recorder := &chromeRecorder{} | |
| 60 | 81 | 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 int | 47 | + | statusSet int |
| 46 | - | statusClear int | 48 | + | statusClear int |
| 47 | - | widgetSet int | 49 | + | widgetSet int |
| 48 | - | widgetClear int | 50 | + | 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) { | |||
| 217 | 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 | 245 | // extToolAdapter bridges *extensions.Manager to the | |
| 221 | 246 | // ExtensionToolSource interface declared in build.go (kept narrow to | |
| 222 | 247 | // 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 the | 245 | // extToolAdapter bridges *extensions.Manager to the | ||
| 221 | // ExtensionToolSource interface declared in build.go (kept narrow to | 246 | // ExtensionToolSource interface declared in build.go (kept narrow to | ||
| 222 | // avoid a build->extensions import cycle). One adapter instance per | 247 | // 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) { | |||
| 520 | 520 | // ClearStatus removes one persistent status item owned by this extension. | |
| 521 | 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 | 534 | func (e *Extension) SetWidget(id, position, title string, lines []string) { | |
| 525 | 535 | _ = e.send(extproto.WidgetFromExt{Type: "widget", ID: id, Position: position, Title: title, Lines: lines}) | |
| 526 | 536 | } | |
| @@ -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) { | |||
| 255 | 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 | 259 | widget := h.drainUntil(t, "widget") | |
| 260 | 260 | var wf extproto.WidgetFromExt | |
| 261 | 261 | if err := json.Unmarshal(widget.raw, &wf); err != nil { | |
| 262 | 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 | 265 | t.Fatalf("widget = %+v", wf) | |
| 266 | 266 | } | |
| 267 | 267 | ||
| @@ -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.WidgetFromExt | 260 | 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 | |||
| 175 | 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 | 180 | t.Cleanup(func() { m.Stop(2 * time.Second) }) | |
| 180 | 181 | ||
| 181 | 182 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| @@ -202,6 +203,12 @@ done | |||
| 202 | 203 | if !reloadFired { | |
| 203 | 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 | 213 | // Registered command should still be there after reload. | |
| 207 | 214 | 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 { | |||
| 199 | 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 | 209 | type commandRegistration struct { | |
| 203 | 210 | ext *Extension | |
| 204 | 211 | name string | |
| @@ -821,6 +828,9 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 821 | 828 | } | |
| 822 | 829 | } | |
| 823 | 830 | m.mu.Unlock() | |
| 831 | + | if hooks, ok := m.hooks.(ExtensionChromeHooks); ok { | |
| 832 | + | hooks.ClearExtensionChrome(ext.Manifest.Name) | |
| 833 | + | } | |
| 824 | 834 | ext.stopLifecycleWriter() | |
| 825 | 835 | ext.readyOnce.Do(func() { close(ext.readyCh) }) | |
| 826 | 836 | 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 *Extension | 210 | ext *Extension | ||
| 204 | name string | 211 | 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) | |||
| 41 | 41 | ||
| 42 | 42 | // stubHooks records every callback so the test can assert on them. | |
| 43 | 43 | 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 | |
| 54 | 55 | } | |
| 55 | 56 | ||
| 56 | 57 | func (s *stubHooks) Notify(name, level, message string) { | |
| @@ -96,6 +97,11 @@ func (s *stubHooks) ClosePanel(string, string) {} | |||
| 96 | 97 | func (s *stubHooks) SetStatus(string, string, string, string) {} | |
| 97 | 98 | func (s *stubHooks) SetWidget(string, string, string, string, []string) {} | |
| 98 | 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 | 106 | // writeMockExtension creates a minimal extension on disk that uses a | |
| 101 | 107 | // 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.Mutex | 44 | + | mu sync.Mutex |
| 45 | - | notifies []string | 45 | + | notifies []string |
| 46 | - | displays []string | 46 | + | displays []string |
| 47 | - | alerts []extproto.AlertRequest | 47 | + | alerts []extproto.AlertRequest |
| 48 | - | alertExts []string | 48 | + | alertExts []string |
| 49 | - | submits []string | 49 | + | submits []string |
| 50 | - | submitSlash []string | 50 | + | submitSlash []string |
| 51 | - | clearNotes []string | 51 | + | clearNotes []string |
| 52 | - | panels []extproto.PanelSpec | 52 | + | panels []extproto.PanelSpec |
| 53 | - | panelExts []string | 53 | + | 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 a | 106 | // writeMockExtension creates a minimal extension on disk that uses a | ||
| 101 | // shell script (or batch file on windows) to drive the protocol. The | 107 | // shell script (or batch file on windows) to drive the protocol. The | ||
M packages/agent/extproto/extproto.go +24 -3
| @@ -17,7 +17,10 @@ | |||
| 17 | 17 | // correlate; events and notifications never carry an ID. | |
| 18 | 18 | package extproto | |
| 19 | 19 | ||
| 20 | - | import "encoding/json" | |
| 20 | + | import ( | |
| 21 | + | "encoding/json" | |
| 22 | + | "strings" | |
| 23 | + | ) | |
| 21 | 24 | ||
| 22 | 25 | const ProtocolVersion = 1 | |
| 23 | 26 | ||
| @@ -136,9 +139,27 @@ type StatusFromExt struct { | |||
| 136 | 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 | 159 | // 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. | |
| 142 | 163 | type WidgetFromExt struct { | |
| 143 | 164 | Type string `json:"type"` | |
| 144 | 165 | 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 extproto | 18 | package extproto | ||
| 19 | 19 | ||||
| 20 | - | import "encoding/json" | 20 | + | import ( |
| 21 | + | "encoding/json" | |||
| 22 | + | "strings" | |||
| 23 | + | ) | |||
| 21 | 24 | ||||
| 22 | const ProtocolVersion = 1 | 25 | 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 an | 159 | // WidgetFromExt sets or replaces one persistent widget owned by an | ||
| 140 | - | // extension. Position is a host-defined placement hint; interactive hosts | 160 | + | // 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) { | |||
| 109 | 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 | + | } | |
| @@ -109,3 +109,72 @@ func TestPersistentExtensionStatusAndWidget(t *testing.T) { | |||||
| 109 | t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets) | 109 | t.Fatalf("persistent extension chrome was not cleared: statuses=%v widgets=%v", i.extStatuses, i.extWidgets) | ||
| 110 | } | 110 | } | ||
| 111 | } | 111 | } | ||
| 112 | + | ||||
| 113 | + | func TestRightBarWidgetsAreDeterministicAndFallbackAboveInput(t *testing.T) { | |||
| 114 | + | i := newNotesTestInteractive() | |||
| 115 | + | i.SetWidget("zeta", "plan", "right_bar", "Zeta", []string{"zeta line"}) | |||
| 116 | + | i.SetWidget("alpha", "second", "right_bar", "Second", []string{"second line"}) | |||
| 117 | + | i.SetWidget("alpha", "first", "right_bar", "First", []string{"first line"}) | |||
| 118 | + | i.SetWidget("legacy", "plan", "above_input", "Legacy", []string{"legacy line"}) | |||
| 119 | + | ||||
| 120 | + | i.mu.Lock() | |||
| 121 | + | widgets := i.rightBarWidgetsLocked() | |||
| 122 | + | wideAbove := i.extensionChromeLinesAtLocked(80, true) | |||
| 123 | + | narrowFallback := i.extensionChromeLinesAtLocked(72, false) | |||
| 124 | + | i.mu.Unlock() | |||
| 125 | + | ||||
| 126 | + | if len(widgets) != 3 || widgets[0].Extension != "alpha" || widgets[0].ID != "first" || widgets[1].ID != "second" || widgets[2].Extension != "zeta" { | |||
| 127 | + | t.Fatalf("right-bar ordering = %+v", widgets) | |||
| 128 | + | } | |||
| 129 | + | wideText := strings.Join(wideAbove, "\n") | |||
| 130 | + | if strings.Contains(wideText, "Zeta") || strings.Contains(wideText, "Second") || strings.Contains(wideText, "First") { | |||
| 131 | + | t.Fatalf("right-bar widgets leaked into wide above-input chrome: %q", wideText) | |||
| 132 | + | } | |||
| 133 | + | fallbackText := strings.Join(narrowFallback, "\n") | |||
| 134 | + | if !strings.Contains(fallbackText, "Zeta") || !strings.Contains(fallbackText, "First") || !strings.Contains(fallbackText, "Legacy") { | |||
| 135 | + | t.Fatalf("narrow fallback omitted widgets: %q", fallbackText) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestClearExtensionChromeRemovesOnlyTheExitedExtension(t *testing.T) { | |||
| 140 | + | i := newNotesTestInteractive() | |||
| 141 | + | i.SetStatus("gone", "progress", "success", "done") | |||
| 142 | + | i.SetWidget("gone", "plan", "right_bar", "Gone", []string{"old"}) | |||
| 143 | + | i.SetStatus("keep", "progress", "info", "still here") | |||
| 144 | + | i.SetWidget("keep", "plan", "right_bar", "Keep", []string{"new"}) | |||
| 145 | + | i.extNotes = []string{"[gone] old note", "[keep] keep note"} | |||
| 146 | + | ||||
| 147 | + | i.ClearExtensionChrome("gone") | |||
| 148 | + | ||||
| 149 | + | if _, ok := i.extStatuses["gone"]; ok { | |||
| 150 | + | t.Fatal("exited extension status survived") | |||
| 151 | + | } | |||
| 152 | + | if _, ok := i.extWidgets["gone"]; ok { | |||
| 153 | + | t.Fatal("exited extension widget survived") | |||
| 154 | + | } | |||
| 155 | + | if _, ok := i.extStatuses["keep"]; !ok { | |||
| 156 | + | t.Fatal("unrelated extension status was removed") | |||
| 157 | + | } | |||
| 158 | + | if _, ok := i.extWidgets["keep"]; !ok { | |||
| 159 | + | t.Fatal("unrelated extension widget was removed") | |||
| 160 | + | } | |||
| 161 | + | if got := strings.Join(i.extNotes, "\n"); got != "[keep] keep note" { | |||
| 162 | + | t.Fatalf("extension notes after cleanup = %q", got) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestInteractiveRedrawPlacesWideRightBarBesideMainPane(t *testing.T) { | |||
| 167 | + | term := &alertTestTerminal{} | |||
| 168 | + | i := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark}) | |||
| 169 | + | i.rend.Resize(80, 24) | |||
| 170 | + | i.SetWidget("tasked-phases", "plan", "right_bar", "Plan", []string{"[ ] read files"}) | |||
| 171 | + | i.redraw() | |||
| 172 | + | ||||
| 173 | + | out := term.String() | |||
| 174 | + | if !strings.Contains(out, "Plan") || !strings.Contains(out, "[ ] read files") { | |||
| 175 | + | t.Fatalf("wide redraw omitted right-bar content: %q", out) | |||
| 176 | + | } | |||
| 177 | + | if !strings.Contains(out, "│") { | |||
| 178 | + | t.Fatal("wide redraw omitted the main-pane divider") | |||
| 179 | + | } | |||
| 180 | + | } | |||
M packages/agent/modes/interactive.go +124 -38
| @@ -1211,7 +1211,30 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||
| 1211 | 1211 | const maxExtensionWidgetRows = 12 | |
| 1212 | 1212 | const maxExtensionStatusRows = 6 | |
| 1213 | 1213 | ||
| 1214 | + | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |
| 1215 | + | var out []tui.RightBarWidget | |
| 1216 | + | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| 1217 | + | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 1218 | + | widget := i.extWidgets[extName][id] | |
| 1219 | + | if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar { | |
| 1220 | + | continue | |
| 1221 | + | } | |
| 1222 | + | out = append(out, tui.RightBarWidget{ | |
| 1223 | + | Extension: extName, | |
| 1224 | + | ID: id, | |
| 1225 | + | Title: widget.Title, | |
| 1226 | + | Lines: append([]string(nil), widget.Lines...), | |
| 1227 | + | }) | |
| 1228 | + | } | |
| 1229 | + | } | |
| 1230 | + | return out | |
| 1231 | + | } | |
| 1232 | + | ||
| 1214 | 1233 | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |
| 1234 | + | return i.extensionChromeLinesAtLocked(cols, false) | |
| 1235 | + | } | |
| 1236 | + | ||
| 1237 | + | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |
| 1215 | 1238 | var out []string | |
| 1216 | 1239 | bodyWidth := cols - 2 | |
| 1217 | 1240 | if bodyWidth < 1 { | |
| @@ -1236,6 +1259,9 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |||
| 1236 | 1259 | } | |
| 1237 | 1260 | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 1238 | 1261 | widget := i.extWidgets[extName][id] | |
| 1262 | + | if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar { | |
| 1263 | + | continue | |
| 1264 | + | } | |
| 1239 | 1265 | label := " [" + extName + "]" | |
| 1240 | 1266 | if widget.Title != "" { | |
| 1241 | 1267 | label += " " + widget.Title | |
| @@ -1536,20 +1562,31 @@ func (i *Interactive) redraw() { | |||
| 1536 | 1562 | i.mu.Lock() | |
| 1537 | 1563 | defer i.mu.Unlock() | |
| 1538 | 1564 | ||
| 1539 | - | cols, _ := i.cfg.Terminal.Size() | |
| 1540 | - | chat := i.cachedChatLocked(cols) | |
| 1565 | + | cols, rows := i.cfg.Terminal.Size() | |
| 1566 | + | mainCols := cols | |
| 1567 | + | rightBarWidgets := i.rightBarWidgetsLocked() | |
| 1568 | + | rightBarActive := false | |
| 1569 | + | var rightBarWidth int | |
| 1570 | + | if len(rightBarWidgets) > 0 { | |
| 1571 | + | if width, rail, ok := tui.RightBarColumns(cols); ok { | |
| 1572 | + | mainCols = width | |
| 1573 | + | rightBarWidth = rail | |
| 1574 | + | rightBarActive = true | |
| 1575 | + | } | |
| 1576 | + | } | |
| 1577 | + | chat := i.cachedChatLocked(mainCols) | |
| 1541 | 1578 | ||
| 1542 | 1579 | // Dialogs (login or model picker) render between chat and the editor. | |
| 1543 | 1580 | var dialog []string | |
| 1544 | 1581 | switch { | |
| 1545 | 1582 | case i.dialog.Active(): | |
| 1546 | - | dialog = i.dialog.Render(i.cfg.Theme, cols) | |
| 1583 | + | dialog = i.dialog.Render(i.cfg.Theme, mainCols) | |
| 1547 | 1584 | case i.modelDialog.Active(): | |
| 1548 | - | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | |
| 1585 | + | dialog = i.modelDialog.Render(i.cfg.Theme, mainCols) | |
| 1549 | 1586 | case i.llamaDialog.Active(): | |
| 1550 | - | dialog = i.llamaDialog.Render(i.cfg.Theme, cols) | |
| 1587 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols) | |
| 1551 | 1588 | case i.rescueDialog.Active(): | |
| 1552 | - | dialog = i.rescueDialog.Render(i.cfg.Theme, cols) | |
| 1589 | + | dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols) | |
| 1553 | 1590 | case i.sessionDialog.Active(): | |
| 1554 | 1591 | // Reserve rows for the editor (~3), status line (1-2), | |
| 1555 | 1592 | // dialog chrome (header + hint + rule + indicators, ~5), | |
| @@ -1561,36 +1598,36 @@ func (i *Interactive) redraw() { | |||
| 1561 | 1598 | avail = 3 | |
| 1562 | 1599 | } | |
| 1563 | 1600 | i.sessionDialog.MaxRows = avail | |
| 1564 | - | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | |
| 1601 | + | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | |
| 1565 | 1602 | case i.subagentsDialog.Active(): | |
| 1566 | - | dialog = i.subagentsDialog.Render(i.cfg.Theme, cols) | |
| 1603 | + | dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols) | |
| 1567 | 1604 | case i.jumpDialog.Active(): | |
| 1568 | - | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | |
| 1605 | + | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | |
| 1569 | 1606 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | |
| 1570 | - | dialog = i.extPanel.Render(i.cfg.Theme, cols) | |
| 1607 | + | dialog = i.extPanel.Render(i.cfg.Theme, mainCols) | |
| 1571 | 1608 | case i.confirmDialog.Active(): | |
| 1572 | 1609 | if i.btwDialog.Active() { | |
| 1573 | 1610 | // Keep the side-chat transcript and its live tool preview visible | |
| 1574 | 1611 | // above confirmation, matching how the main transcript remains | |
| 1575 | 1612 | // visible while --no-yolo waits for a decision. | |
| 1576 | - | dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog) | |
| 1613 | + | dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog) | |
| 1577 | 1614 | } else { | |
| 1578 | - | dialog = i.confirmDialog.Render(i.cfg.Theme, cols) | |
| 1615 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols) | |
| 1579 | 1616 | } | |
| 1580 | 1617 | case i.btwDialog.Active(): | |
| 1581 | - | dialog = i.btwDialog.Render(i.cfg.Theme, cols) | |
| 1618 | + | dialog = i.btwDialog.Render(i.cfg.Theme, mainCols) | |
| 1582 | 1619 | case i.skillsDialog.Active(): | |
| 1583 | - | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | |
| 1620 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols) | |
| 1584 | 1621 | case i.changelogDialog.Active(): | |
| 1585 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | |
| 1622 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) | |
| 1586 | 1623 | case i.logoutDialog.Active(): | |
| 1587 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, cols) | |
| 1624 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) | |
| 1588 | 1625 | case i.telegramDialog.Active(): | |
| 1589 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, cols) | |
| 1626 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) | |
| 1590 | 1627 | case i.settingsDialog.Active(): | |
| 1591 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, cols) | |
| 1628 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) | |
| 1592 | 1629 | case i.sessionOpsDialog.Active(): | |
| 1593 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | |
| 1630 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) | |
| 1594 | 1631 | case i.sessionTreeDialog.Active(): | |
| 1595 | 1632 | // Reserve rows for the editor, status, and tree chrome while using | |
| 1596 | 1633 | // the same budget for rendering and PageUp/PageDown movement. | |
| @@ -1600,7 +1637,7 @@ func (i *Interactive) redraw() { | |||
| 1600 | 1637 | avail = 3 | |
| 1601 | 1638 | } | |
| 1602 | 1639 | i.sessionTreeDialog.MaxRows = avail | |
| 1603 | - | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols) | |
| 1640 | + | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols) | |
| 1604 | 1641 | } | |
| 1605 | 1642 | if len(dialog) > 0 { | |
| 1606 | 1643 | dialog = padDialogFrame(dialog) | |
| @@ -1649,9 +1686,9 @@ func (i *Interactive) redraw() { | |||
| 1649 | 1686 | i.fileSuggest.SetCWD(i.cfg.CWD) | |
| 1650 | 1687 | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive()) | |
| 1651 | 1688 | if mainInputFocused && i.suggest.Active(currentInput) { | |
| 1652 | - | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | |
| 1689 | + | suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols) | |
| 1653 | 1690 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | |
| 1654 | - | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols) | |
| 1691 | + | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols) | |
| 1655 | 1692 | } | |
| 1656 | 1693 | ||
| 1657 | 1694 | // Detect overlay close (any dialog or slash/file suggestion popup | |
| @@ -1725,7 +1762,7 @@ func (i *Interactive) redraw() { | |||
| 1725 | 1762 | ContextMax: ctxMax, | |
| 1726 | 1763 | AutoCompacting: i.autoCompacting, | |
| 1727 | 1764 | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | |
| 1728 | - | Cols: cols, | |
| 1765 | + | Cols: mainCols, | |
| 1729 | 1766 | }) | |
| 1730 | 1767 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 1731 | 1768 | if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock { | |
| @@ -1733,7 +1770,7 @@ func (i *Interactive) redraw() { | |||
| 1733 | 1770 | } else { | |
| 1734 | 1771 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | |
| 1735 | 1772 | } | |
| 1736 | - | edLines, curR, curC := i.ed.Render(cols) | |
| 1773 | + | edLines, curR, curC := i.ed.Render(mainCols) | |
| 1737 | 1774 | var workingLines []string | |
| 1738 | 1775 | if busyPrefix != "" && !workingWithStatus { | |
| 1739 | 1776 | workingLines = []string{" " + busyPrefix} | |
| @@ -1742,10 +1779,10 @@ func (i *Interactive) redraw() { | |||
| 1742 | 1779 | inputCursorColOffset := 0 | |
| 1743 | 1780 | switch inputStyle { | |
| 1744 | 1781 | case tui.InputStyleLines: | |
| 1745 | - | edLines = tui.InputLines(i.cfg.Theme, edLines, cols) | |
| 1782 | + | edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols) | |
| 1746 | 1783 | inputCursorOffset = 1 | |
| 1747 | 1784 | case tui.InputStyleBlock: | |
| 1748 | - | edLines = tui.InputBlock(i.cfg.Theme, edLines, cols) | |
| 1785 | + | edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols) | |
| 1749 | 1786 | inputCursorOffset = 1 | |
| 1750 | 1787 | inputCursorColOffset = 2 | |
| 1751 | 1788 | } | |
| @@ -1762,7 +1799,7 @@ func (i *Interactive) redraw() { | |||
| 1762 | 1799 | queue = append(queue, "") | |
| 1763 | 1800 | for _, q := range queued { | |
| 1764 | 1801 | label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ") | |
| 1765 | - | text := truncateLine(q, cols-17) | |
| 1802 | + | text := truncateLine(q, mainCols-17) | |
| 1766 | 1803 | queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) | |
| 1767 | 1804 | } | |
| 1768 | 1805 | // Hint row, rendered in the same muted tone as the model | |
| @@ -1773,7 +1810,7 @@ func (i *Interactive) redraw() { | |||
| 1773 | 1810 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | |
| 1774 | 1811 | } | |
| 1775 | 1812 | ||
| 1776 | - | extensionLines := i.extensionChromeLinesLocked(cols) | |
| 1813 | + | extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive) | |
| 1777 | 1814 | ||
| 1778 | 1815 | // Bottom-sticky sections (always visible, never scroll). Each | |
| 1779 | 1816 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | |
| @@ -1837,8 +1874,12 @@ func (i *Interactive) redraw() { | |||
| 1837 | 1874 | } | |
| 1838 | 1875 | } | |
| 1839 | 1876 | ||
| 1840 | - | _, rows := i.cfg.Terminal.Size() | |
| 1841 | 1877 | chatRows := rows - len(bottom) | |
| 1878 | + | if rightBarActive { | |
| 1879 | + | // DrawRightBar keeps the renderer-owned bottom margin inside the | |
| 1880 | + | // fixed frame, so reserve that row before selecting visible chat. | |
| 1881 | + | chatRows-- | |
| 1882 | + | } | |
| 1842 | 1883 | if chatRows < 1 { | |
| 1843 | 1884 | chatRows = 1 | |
| 1844 | 1885 | } | |
| @@ -1866,12 +1907,12 @@ func (i *Interactive) redraw() { | |||
| 1866 | 1907 | // let a shrinking chatRows pull the window toward the tail, which | |
| 1867 | 1908 | // read as the viewport jumping to the bottom whenever the agent | |
| 1868 | 1909 | // streamed text or a tool call grew the bottom band. | |
| 1869 | - | if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 { | |
| 1910 | + | if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 { | |
| 1870 | 1911 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | |
| 1871 | 1912 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | |
| 1872 | 1913 | } | |
| 1873 | 1914 | i.prevChatLen = len(chat) | |
| 1874 | - | i.prevChatCols = cols | |
| 1915 | + | i.prevChatCols = mainCols | |
| 1875 | 1916 | i.prevChatRows = chatRows | |
| 1876 | 1917 | ||
| 1877 | 1918 | // Apply scroll offset to the chat slice. | |
| @@ -1893,7 +1934,7 @@ func (i *Interactive) redraw() { | |||
| 1893 | 1934 | i.view.TailLimit = 0 // unbounded | |
| 1894 | 1935 | } | |
| 1895 | 1936 | i.chatCacheValid = false | |
| 1896 | - | chat = i.cachedChatLocked(cols) | |
| 1937 | + | chat = i.cachedChatLocked(mainCols) | |
| 1897 | 1938 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | |
| 1898 | 1939 | chat = chat[:len(chat)-1] | |
| 1899 | 1940 | } | |
| @@ -1909,7 +1950,7 @@ func (i *Interactive) redraw() { | |||
| 1909 | 1950 | i.scrollOffset += grew | |
| 1910 | 1951 | } | |
| 1911 | 1952 | i.prevChatLen = len(chat) | |
| 1912 | - | i.prevChatCols = cols | |
| 1953 | + | i.prevChatCols = mainCols | |
| 1913 | 1954 | maxOffset = len(chat) - chatRows | |
| 1914 | 1955 | if maxOffset < 0 { | |
| 1915 | 1956 | maxOffset = 0 | |
| @@ -1994,13 +2035,13 @@ func (i *Interactive) redraw() { | |||
| 1994 | 2035 | cursorRow := inputStartRow + inputCursorOffset + curR | |
| 1995 | 2036 | cursorCol := curC + inputCursorColOffset | |
| 1996 | 2037 | if i.btwDialog.Active() { | |
| 1997 | - | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | |
| 2038 | + | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | |
| 1998 | 2039 | cursorRow = dialogLead + r | |
| 1999 | 2040 | cursorCol = c | |
| 2000 | 2041 | } | |
| 2001 | 2042 | } | |
| 2002 | 2043 | if i.dialog.Active() { | |
| 2003 | - | if r, c := i.dialog.CursorPos(cols); r >= 0 { | |
| 2044 | + | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | |
| 2004 | 2045 | cursorRow = dialogLead + r | |
| 2005 | 2046 | cursorCol = c | |
| 2006 | 2047 | } | |
| @@ -2027,7 +2068,7 @@ func (i *Interactive) redraw() { | |||
| 2027 | 2068 | } | |
| 2028 | 2069 | } | |
| 2029 | 2070 | if i.subagentsDialog.Active() { | |
| 2030 | - | if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 { | |
| 2071 | + | if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 { | |
| 2031 | 2072 | cursorRow = dialogLead + r | |
| 2032 | 2073 | cursorCol = c | |
| 2033 | 2074 | } else { | |
| @@ -2043,8 +2084,13 @@ func (i *Interactive) redraw() { | |||
| 2043 | 2084 | cursorRow = -1 | |
| 2044 | 2085 | cursorCol = 0 | |
| 2045 | 2086 | } | |
| 2046 | - | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2047 | - | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 2087 | + | if rightBarActive { | |
| 2088 | + | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) | |
| 2089 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 2090 | + | } else { | |
| 2091 | + | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2092 | + | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 2093 | + | } | |
| 2048 | 2094 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | |
| 2049 | 2095 | alert := *i.pendingAlert | |
| 2050 | 2096 | i.pendingAlert = nil | |
| @@ -3704,6 +3750,7 @@ func (i *Interactive) SetWidget(extName, id, position, title string, lines []str | |||
| 3704 | 3750 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | |
| 3705 | 3751 | return | |
| 3706 | 3752 | } | |
| 3753 | + | position = extproto.NormalizeWidgetPosition(position) | |
| 3707 | 3754 | i.mu.Lock() | |
| 3708 | 3755 | if i.extWidgets == nil { | |
| 3709 | 3756 | i.extWidgets = map[string]map[string]extensionWidget{} | |
| @@ -3733,6 +3780,45 @@ func (i *Interactive) ClearWidget(extName, id string) { | |||
| 3733 | 3780 | i.invalidate() | |
| 3734 | 3781 | } | |
| 3735 | 3782 | ||
| 3783 | + | // ClearExtensionChrome removes every persistent UI item owned by an | |
| 3784 | + | // extension that has exited. This keeps crashes, disable/reload operations, | |
| 3785 | + | // and startup failures from leaving stale widgets beside the transcript. | |
| 3786 | + | func (i *Interactive) ClearExtensionChrome(extName string) { | |
| 3787 | + | if strings.TrimSpace(extName) == "" { | |
| 3788 | + | return | |
| 3789 | + | } | |
| 3790 | + | marker := "[" + extName + "] " | |
| 3791 | + | i.mu.Lock() | |
| 3792 | + | changed := false | |
| 3793 | + | if _, ok := i.extStatuses[extName]; ok { | |
| 3794 | + | delete(i.extStatuses, extName) | |
| 3795 | + | changed = true | |
| 3796 | + | } | |
| 3797 | + | if _, ok := i.extWidgets[extName]; ok { | |
| 3798 | + | delete(i.extWidgets, extName) | |
| 3799 | + | changed = true | |
| 3800 | + | } | |
| 3801 | + | if len(i.extNotes) > 0 { | |
| 3802 | + | kept := i.extNotes[:0:0] | |
| 3803 | + | for _, line := range i.extNotes { | |
| 3804 | + | if strings.Contains(line, marker) { | |
| 3805 | + | changed = true | |
| 3806 | + | continue | |
| 3807 | + | } | |
| 3808 | + | kept = append(kept, line) | |
| 3809 | + | } | |
| 3810 | + | i.extNotes = kept | |
| 3811 | + | } | |
| 3812 | + | if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName { | |
| 3813 | + | i.extPanel.Close() | |
| 3814 | + | changed = true | |
| 3815 | + | } | |
| 3816 | + | i.mu.Unlock() | |
| 3817 | + | if changed { | |
| 3818 | + | i.invalidate() | |
| 3819 | + | } | |
| 3820 | + | } | |
| 3821 | + | ||
| 3736 | 3822 | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | |
| 3737 | 3823 | i.mu.Lock() | |
| 3738 | 3824 | defer i.mu.Unlock() | |
| @@ -1211,7 +1211,30 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||||
| 1211 | const maxExtensionWidgetRows = 12 | 1211 | const maxExtensionWidgetRows = 12 | ||
| 1212 | const maxExtensionStatusRows = 6 | 1212 | const maxExtensionStatusRows = 6 | ||
| 1213 | 1213 | ||||
| 1214 | + | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |||
| 1215 | + | var out []tui.RightBarWidget | |||
| 1216 | + | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |||
| 1217 | + | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |||
| 1218 | + | widget := i.extWidgets[extName][id] | |||
| 1219 | + | if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar { | |||
| 1220 | + | continue | |||
| 1221 | + | } | |||
| 1222 | + | out = append(out, tui.RightBarWidget{ | |||
| 1223 | + | Extension: extName, | |||
| 1224 | + | ID: id, | |||
| 1225 | + | Title: widget.Title, | |||
| 1226 | + | Lines: append([]string(nil), widget.Lines...), | |||
| 1227 | + | }) | |||
| 1228 | + | } | |||
| 1229 | + | } | |||
| 1230 | + | return out | |||
| 1231 | + | } | |||
| 1232 | + | ||||
| 1214 | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | 1233 | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | ||
| 1234 | + | return i.extensionChromeLinesAtLocked(cols, false) | |||
| 1235 | + | } | |||
| 1236 | + | ||||
| 1237 | + | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |||
| 1215 | var out []string | 1238 | var out []string | ||
| 1216 | bodyWidth := cols - 2 | 1239 | bodyWidth := cols - 2 | ||
| 1217 | if bodyWidth < 1 { | 1240 | if bodyWidth < 1 { | ||
| @@ -1236,6 +1259,9 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |||||
| 1236 | } | 1259 | } | ||
| 1237 | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | 1260 | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | ||
| 1238 | widget := i.extWidgets[extName][id] | 1261 | widget := i.extWidgets[extName][id] | ||
| 1262 | + | if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar { | |||
| 1263 | + | continue | |||
| 1264 | + | } | |||
| 1239 | label := " [" + extName + "]" | 1265 | label := " [" + extName + "]" | ||
| 1240 | if widget.Title != "" { | 1266 | if widget.Title != "" { | ||
| 1241 | label += " " + widget.Title | 1267 | label += " " + widget.Title | ||
| @@ -1536,20 +1562,31 @@ func (i *Interactive) redraw() { | |||||
| 1536 | i.mu.Lock() | 1562 | i.mu.Lock() | ||
| 1537 | defer i.mu.Unlock() | 1563 | defer i.mu.Unlock() | ||
| 1538 | 1564 | ||||
| 1539 | - | cols, _ := i.cfg.Terminal.Size() | 1565 | + | cols, rows := i.cfg.Terminal.Size() |
| 1540 | - | chat := i.cachedChatLocked(cols) | 1566 | + | mainCols := cols |
| 1567 | + | rightBarWidgets := i.rightBarWidgetsLocked() | |||
| 1568 | + | rightBarActive := false | |||
| 1569 | + | var rightBarWidth int | |||
| 1570 | + | if len(rightBarWidgets) > 0 { | |||
| 1571 | + | if width, rail, ok := tui.RightBarColumns(cols); ok { | |||
| 1572 | + | mainCols = width | |||
| 1573 | + | rightBarWidth = rail | |||
| 1574 | + | rightBarActive = true | |||
| 1575 | + | } | |||
| 1576 | + | } | |||
| 1577 | + | chat := i.cachedChatLocked(mainCols) | |||
| 1541 | 1578 | ||||
| 1542 | // Dialogs (login or model picker) render between chat and the editor. | 1579 | // Dialogs (login or model picker) render between chat and the editor. | ||
| 1543 | var dialog []string | 1580 | var dialog []string | ||
| 1544 | switch { | 1581 | switch { | ||
| 1545 | case i.dialog.Active(): | 1582 | case i.dialog.Active(): | ||
| 1546 | - | dialog = i.dialog.Render(i.cfg.Theme, cols) | 1583 | + | dialog = i.dialog.Render(i.cfg.Theme, mainCols) |
| 1547 | case i.modelDialog.Active(): | 1584 | case i.modelDialog.Active(): | ||
| 1548 | - | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | 1585 | + | dialog = i.modelDialog.Render(i.cfg.Theme, mainCols) |
| 1549 | case i.llamaDialog.Active(): | 1586 | case i.llamaDialog.Active(): | ||
| 1550 | - | dialog = i.llamaDialog.Render(i.cfg.Theme, cols) | 1587 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols) |
| 1551 | case i.rescueDialog.Active(): | 1588 | case i.rescueDialog.Active(): | ||
| 1552 | - | dialog = i.rescueDialog.Render(i.cfg.Theme, cols) | 1589 | + | dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols) |
| 1553 | case i.sessionDialog.Active(): | 1590 | case i.sessionDialog.Active(): | ||
| 1554 | // Reserve rows for the editor (~3), status line (1-2), | 1591 | // Reserve rows for the editor (~3), status line (1-2), | ||
| 1555 | // dialog chrome (header + hint + rule + indicators, ~5), | 1592 | // dialog chrome (header + hint + rule + indicators, ~5), | ||
| @@ -1561,36 +1598,36 @@ func (i *Interactive) redraw() { | |||||
| 1561 | avail = 3 | 1598 | avail = 3 | ||
| 1562 | } | 1599 | } | ||
| 1563 | i.sessionDialog.MaxRows = avail | 1600 | i.sessionDialog.MaxRows = avail | ||
| 1564 | - | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | 1601 | + | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) |
| 1565 | case i.subagentsDialog.Active(): | 1602 | case i.subagentsDialog.Active(): | ||
| 1566 | - | dialog = i.subagentsDialog.Render(i.cfg.Theme, cols) | 1603 | + | dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols) |
| 1567 | case i.jumpDialog.Active(): | 1604 | case i.jumpDialog.Active(): | ||
| 1568 | - | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | 1605 | + | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) |
| 1569 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | 1606 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | ||
| 1570 | - | dialog = i.extPanel.Render(i.cfg.Theme, cols) | 1607 | + | dialog = i.extPanel.Render(i.cfg.Theme, mainCols) |
| 1571 | case i.confirmDialog.Active(): | 1608 | case i.confirmDialog.Active(): | ||
| 1572 | if i.btwDialog.Active() { | 1609 | if i.btwDialog.Active() { | ||
| 1573 | // Keep the side-chat transcript and its live tool preview visible | 1610 | // Keep the side-chat transcript and its live tool preview visible | ||
| 1574 | // above confirmation, matching how the main transcript remains | 1611 | // above confirmation, matching how the main transcript remains | ||
| 1575 | // visible while --no-yolo waits for a decision. | 1612 | // visible while --no-yolo waits for a decision. | ||
| 1576 | - | dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog) | 1613 | + | dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog) |
| 1577 | } else { | 1614 | } else { | ||
| 1578 | - | dialog = i.confirmDialog.Render(i.cfg.Theme, cols) | 1615 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols) |
| 1579 | } | 1616 | } | ||
| 1580 | case i.btwDialog.Active(): | 1617 | case i.btwDialog.Active(): | ||
| 1581 | - | dialog = i.btwDialog.Render(i.cfg.Theme, cols) | 1618 | + | dialog = i.btwDialog.Render(i.cfg.Theme, mainCols) |
| 1582 | case i.skillsDialog.Active(): | 1619 | case i.skillsDialog.Active(): | ||
| 1583 | - | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | 1620 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols) |
| 1584 | case i.changelogDialog.Active(): | 1621 | case i.changelogDialog.Active(): | ||
| 1585 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | 1622 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) |
| 1586 | case i.logoutDialog.Active(): | 1623 | case i.logoutDialog.Active(): | ||
| 1587 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, cols) | 1624 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) |
| 1588 | case i.telegramDialog.Active(): | 1625 | case i.telegramDialog.Active(): | ||
| 1589 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, cols) | 1626 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) |
| 1590 | case i.settingsDialog.Active(): | 1627 | case i.settingsDialog.Active(): | ||
| 1591 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, cols) | 1628 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) |
| 1592 | case i.sessionOpsDialog.Active(): | 1629 | case i.sessionOpsDialog.Active(): | ||
| 1593 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | 1630 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) |
| 1594 | case i.sessionTreeDialog.Active(): | 1631 | case i.sessionTreeDialog.Active(): | ||
| 1595 | // Reserve rows for the editor, status, and tree chrome while using | 1632 | // Reserve rows for the editor, status, and tree chrome while using | ||
| 1596 | // the same budget for rendering and PageUp/PageDown movement. | 1633 | // the same budget for rendering and PageUp/PageDown movement. | ||
| @@ -1600,7 +1637,7 @@ func (i *Interactive) redraw() { | |||||
| 1600 | avail = 3 | 1637 | avail = 3 | ||
| 1601 | } | 1638 | } | ||
| 1602 | i.sessionTreeDialog.MaxRows = avail | 1639 | i.sessionTreeDialog.MaxRows = avail | ||
| 1603 | - | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols) | 1640 | + | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols) |
| 1604 | } | 1641 | } | ||
| 1605 | if len(dialog) > 0 { | 1642 | if len(dialog) > 0 { | ||
| 1606 | dialog = padDialogFrame(dialog) | 1643 | dialog = padDialogFrame(dialog) | ||
| @@ -1649,9 +1686,9 @@ func (i *Interactive) redraw() { | |||||
| 1649 | i.fileSuggest.SetCWD(i.cfg.CWD) | 1686 | i.fileSuggest.SetCWD(i.cfg.CWD) | ||
| 1650 | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive()) | 1687 | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActive()) | ||
| 1651 | if mainInputFocused && i.suggest.Active(currentInput) { | 1688 | if mainInputFocused && i.suggest.Active(currentInput) { | ||
| 1652 | - | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | 1689 | + | suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols) |
| 1653 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | 1690 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | ||
| 1654 | - | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols) | 1691 | + | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols) |
| 1655 | } | 1692 | } | ||
| 1656 | 1693 | ||||
| 1657 | // Detect overlay close (any dialog or slash/file suggestion popup | 1694 | // Detect overlay close (any dialog or slash/file suggestion popup | ||
| @@ -1725,7 +1762,7 @@ func (i *Interactive) redraw() { | |||||
| 1725 | ContextMax: ctxMax, | 1762 | ContextMax: ctxMax, | ||
| 1726 | AutoCompacting: i.autoCompacting, | 1763 | AutoCompacting: i.autoCompacting, | ||
| 1727 | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | 1764 | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | ||
| 1728 | - | Cols: cols, | 1765 | + | Cols: mainCols, |
| 1729 | }) | 1766 | }) | ||
| 1730 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | 1767 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | ||
| 1731 | if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock { | 1768 | if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock { | ||
| @@ -1733,7 +1770,7 @@ func (i *Interactive) redraw() { | |||||
| 1733 | } else { | 1770 | } else { | ||
| 1734 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | 1771 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | ||
| 1735 | } | 1772 | } | ||
| 1736 | - | edLines, curR, curC := i.ed.Render(cols) | 1773 | + | edLines, curR, curC := i.ed.Render(mainCols) |
| 1737 | var workingLines []string | 1774 | var workingLines []string | ||
| 1738 | if busyPrefix != "" && !workingWithStatus { | 1775 | if busyPrefix != "" && !workingWithStatus { | ||
| 1739 | workingLines = []string{" " + busyPrefix} | 1776 | workingLines = []string{" " + busyPrefix} | ||
| @@ -1742,10 +1779,10 @@ func (i *Interactive) redraw() { | |||||
| 1742 | inputCursorColOffset := 0 | 1779 | inputCursorColOffset := 0 | ||
| 1743 | switch inputStyle { | 1780 | switch inputStyle { | ||
| 1744 | case tui.InputStyleLines: | 1781 | case tui.InputStyleLines: | ||
| 1745 | - | edLines = tui.InputLines(i.cfg.Theme, edLines, cols) | 1782 | + | edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols) |
| 1746 | inputCursorOffset = 1 | 1783 | inputCursorOffset = 1 | ||
| 1747 | case tui.InputStyleBlock: | 1784 | case tui.InputStyleBlock: | ||
| 1748 | - | edLines = tui.InputBlock(i.cfg.Theme, edLines, cols) | 1785 | + | edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols) |
| 1749 | inputCursorOffset = 1 | 1786 | inputCursorOffset = 1 | ||
| 1750 | inputCursorColOffset = 2 | 1787 | inputCursorColOffset = 2 | ||
| 1751 | } | 1788 | } | ||
| @@ -1762,7 +1799,7 @@ func (i *Interactive) redraw() { | |||||
| 1762 | queue = append(queue, "") | 1799 | queue = append(queue, "") | ||
| 1763 | for _, q := range queued { | 1800 | for _, q := range queued { | ||
| 1764 | label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ") | 1801 | label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ") | ||
| 1765 | - | text := truncateLine(q, cols-17) | 1802 | + | text := truncateLine(q, mainCols-17) |
| 1766 | queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) | 1803 | queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) | ||
| 1767 | } | 1804 | } | ||
| 1768 | // Hint row, rendered in the same muted tone as the model | 1805 | // Hint row, rendered in the same muted tone as the model | ||
| @@ -1773,7 +1810,7 @@ func (i *Interactive) redraw() { | |||||
| 1773 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | 1810 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | ||
| 1774 | } | 1811 | } | ||
| 1775 | 1812 | ||||
| 1776 | - | extensionLines := i.extensionChromeLinesLocked(cols) | 1813 | + | extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive) |
| 1777 | 1814 | ||||
| 1778 | // Bottom-sticky sections (always visible, never scroll). Each | 1815 | // Bottom-sticky sections (always visible, never scroll). Each | ||
| 1779 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | 1816 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | ||
| @@ -1837,8 +1874,12 @@ func (i *Interactive) redraw() { | |||||
| 1837 | } | 1874 | } | ||
| 1838 | } | 1875 | } | ||
| 1839 | 1876 | ||||
| 1840 | - | _, rows := i.cfg.Terminal.Size() | |||
| 1841 | chatRows := rows - len(bottom) | 1877 | chatRows := rows - len(bottom) | ||
| 1878 | + | if rightBarActive { | |||
| 1879 | + | // DrawRightBar keeps the renderer-owned bottom margin inside the | |||
| 1880 | + | // fixed frame, so reserve that row before selecting visible chat. | |||
| 1881 | + | chatRows-- | |||
| 1882 | + | } | |||
| 1842 | if chatRows < 1 { | 1883 | if chatRows < 1 { | ||
| 1843 | chatRows = 1 | 1884 | chatRows = 1 | ||
| 1844 | } | 1885 | } | ||
| @@ -1866,12 +1907,12 @@ func (i *Interactive) redraw() { | |||||
| 1866 | // let a shrinking chatRows pull the window toward the tail, which | 1907 | // let a shrinking chatRows pull the window toward the tail, which | ||
| 1867 | // read as the viewport jumping to the bottom whenever the agent | 1908 | // read as the viewport jumping to the bottom whenever the agent | ||
| 1868 | // streamed text or a tool call grew the bottom band. | 1909 | // streamed text or a tool call grew the bottom band. | ||
| 1869 | - | if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 { | 1910 | + | if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 { |
| 1870 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | 1911 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | ||
| 1871 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | 1912 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | ||
| 1872 | } | 1913 | } | ||
| 1873 | i.prevChatLen = len(chat) | 1914 | i.prevChatLen = len(chat) | ||
| 1874 | - | i.prevChatCols = cols | 1915 | + | i.prevChatCols = mainCols |
| 1875 | i.prevChatRows = chatRows | 1916 | i.prevChatRows = chatRows | ||
| 1876 | 1917 | ||||
| 1877 | // Apply scroll offset to the chat slice. | 1918 | // Apply scroll offset to the chat slice. | ||
| @@ -1893,7 +1934,7 @@ func (i *Interactive) redraw() { | |||||
| 1893 | i.view.TailLimit = 0 // unbounded | 1934 | i.view.TailLimit = 0 // unbounded | ||
| 1894 | } | 1935 | } | ||
| 1895 | i.chatCacheValid = false | 1936 | i.chatCacheValid = false | ||
| 1896 | - | chat = i.cachedChatLocked(cols) | 1937 | + | chat = i.cachedChatLocked(mainCols) |
| 1897 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | 1938 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | ||
| 1898 | chat = chat[:len(chat)-1] | 1939 | chat = chat[:len(chat)-1] | ||
| 1899 | } | 1940 | } | ||
| @@ -1909,7 +1950,7 @@ func (i *Interactive) redraw() { | |||||
| 1909 | i.scrollOffset += grew | 1950 | i.scrollOffset += grew | ||
| 1910 | } | 1951 | } | ||
| 1911 | i.prevChatLen = len(chat) | 1952 | i.prevChatLen = len(chat) | ||
| 1912 | - | i.prevChatCols = cols | 1953 | + | i.prevChatCols = mainCols |
| 1913 | maxOffset = len(chat) - chatRows | 1954 | maxOffset = len(chat) - chatRows | ||
| 1914 | if maxOffset < 0 { | 1955 | if maxOffset < 0 { | ||
| 1915 | maxOffset = 0 | 1956 | maxOffset = 0 | ||
| @@ -1994,13 +2035,13 @@ func (i *Interactive) redraw() { | |||||
| 1994 | cursorRow := inputStartRow + inputCursorOffset + curR | 2035 | cursorRow := inputStartRow + inputCursorOffset + curR | ||
| 1995 | cursorCol := curC + inputCursorColOffset | 2036 | cursorCol := curC + inputCursorColOffset | ||
| 1996 | if i.btwDialog.Active() { | 2037 | if i.btwDialog.Active() { | ||
| 1997 | - | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | 2038 | + | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { |
| 1998 | cursorRow = dialogLead + r | 2039 | cursorRow = dialogLead + r | ||
| 1999 | cursorCol = c | 2040 | cursorCol = c | ||
| 2000 | } | 2041 | } | ||
| 2001 | } | 2042 | } | ||
| 2002 | if i.dialog.Active() { | 2043 | if i.dialog.Active() { | ||
| 2003 | - | if r, c := i.dialog.CursorPos(cols); r >= 0 { | 2044 | + | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { |
| 2004 | cursorRow = dialogLead + r | 2045 | cursorRow = dialogLead + r | ||
| 2005 | cursorCol = c | 2046 | cursorCol = c | ||
| 2006 | } | 2047 | } | ||
| @@ -2027,7 +2068,7 @@ func (i *Interactive) redraw() { | |||||
| 2027 | } | 2068 | } | ||
| 2028 | } | 2069 | } | ||
| 2029 | if i.subagentsDialog.Active() { | 2070 | if i.subagentsDialog.Active() { | ||
| 2030 | - | if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 { | 2071 | + | if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 { |
| 2031 | cursorRow = dialogLead + r | 2072 | cursorRow = dialogLead + r | ||
| 2032 | cursorCol = c | 2073 | cursorCol = c | ||
| 2033 | } else { | 2074 | } else { | ||
| @@ -2043,8 +2084,13 @@ func (i *Interactive) redraw() { | |||||
| 2043 | cursorRow = -1 | 2084 | cursorRow = -1 | ||
| 2044 | cursorCol = 0 | 2085 | cursorCol = 0 | ||
| 2045 | } | 2086 | } | ||
| 2046 | - | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 2087 | + | if rightBarActive { |
| 2047 | - | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 2088 | + | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) |
| 2089 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |||
| 2090 | + | } else { | |||
| 2091 | + | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |||
| 2092 | + | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |||
| 2093 | + | } | |||
| 2048 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | 2094 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | ||
| 2049 | alert := *i.pendingAlert | 2095 | alert := *i.pendingAlert | ||
| 2050 | i.pendingAlert = nil | 2096 | i.pendingAlert = nil | ||
| @@ -3704,6 +3750,7 @@ func (i *Interactive) SetWidget(extName, id, position, title string, lines []str | |||||
| 3704 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | 3750 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | ||
| 3705 | return | 3751 | return | ||
| 3706 | } | 3752 | } | ||
| 3753 | + | position = extproto.NormalizeWidgetPosition(position) | |||
| 3707 | i.mu.Lock() | 3754 | i.mu.Lock() | ||
| 3708 | if i.extWidgets == nil { | 3755 | if i.extWidgets == nil { | ||
| 3709 | i.extWidgets = map[string]map[string]extensionWidget{} | 3756 | i.extWidgets = map[string]map[string]extensionWidget{} | ||
| @@ -3733,6 +3780,45 @@ func (i *Interactive) ClearWidget(extName, id string) { | |||||
| 3733 | i.invalidate() | 3780 | i.invalidate() | ||
| 3734 | } | 3781 | } | ||
| 3735 | 3782 | ||||
| 3783 | + | // ClearExtensionChrome removes every persistent UI item owned by an | |||
| 3784 | + | // extension that has exited. This keeps crashes, disable/reload operations, | |||
| 3785 | + | // and startup failures from leaving stale widgets beside the transcript. | |||
| 3786 | + | func (i *Interactive) ClearExtensionChrome(extName string) { | |||
| 3787 | + | if strings.TrimSpace(extName) == "" { | |||
| 3788 | + | return | |||
| 3789 | + | } | |||
| 3790 | + | marker := "[" + extName + "] " | |||
| 3791 | + | i.mu.Lock() | |||
| 3792 | + | changed := false | |||
| 3793 | + | if _, ok := i.extStatuses[extName]; ok { | |||
| 3794 | + | delete(i.extStatuses, extName) | |||
| 3795 | + | changed = true | |||
| 3796 | + | } | |||
| 3797 | + | if _, ok := i.extWidgets[extName]; ok { | |||
| 3798 | + | delete(i.extWidgets, extName) | |||
| 3799 | + | changed = true | |||
| 3800 | + | } | |||
| 3801 | + | if len(i.extNotes) > 0 { | |||
| 3802 | + | kept := i.extNotes[:0:0] | |||
| 3803 | + | for _, line := range i.extNotes { | |||
| 3804 | + | if strings.Contains(line, marker) { | |||
| 3805 | + | changed = true | |||
| 3806 | + | continue | |||
| 3807 | + | } | |||
| 3808 | + | kept = append(kept, line) | |||
| 3809 | + | } | |||
| 3810 | + | i.extNotes = kept | |||
| 3811 | + | } | |||
| 3812 | + | if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName { | |||
| 3813 | + | i.extPanel.Close() | |||
| 3814 | + | changed = true | |||
| 3815 | + | } | |||
| 3816 | + | i.mu.Unlock() | |||
| 3817 | + | if changed { | |||
| 3818 | + | i.invalidate() | |||
| 3819 | + | } | |||
| 3820 | + | } | |||
| 3821 | + | ||||
| 3736 | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | 3822 | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | ||
| 3737 | i.mu.Lock() | 3823 | i.mu.Lock() | ||
| 3738 | defer i.mu.Unlock() | 3824 | defer i.mu.Unlock() | ||
M packages/agent/rpc.go +8 -0
| @@ -200,6 +200,14 @@ func (h *rpcExtHooks) ClearWidget(extName, id string) { | |||
| 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 | 211 | const maxPendingRPCExtEvents = 64 | |
| 204 | 212 | ||
| 205 | 213 | 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 = 64 | 211 | 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 | + | } | |||