Merge pull request #26 from bnema/feat/issue-17-right-bar
feat(agent): add extension right-bar widgets
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
| @@ -1235,7 +1235,30 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||
| 1235 | 1235 | const maxExtensionWidgetRows = 12 | |
| 1236 | 1236 | const maxExtensionStatusRows = 6 | |
| 1237 | 1237 | ||
| 1238 | + | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |
| 1239 | + | var out []tui.RightBarWidget | |
| 1240 | + | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |
| 1241 | + | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 1242 | + | widget := i.extWidgets[extName][id] | |
| 1243 | + | if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar { | |
| 1244 | + | continue | |
| 1245 | + | } | |
| 1246 | + | out = append(out, tui.RightBarWidget{ | |
| 1247 | + | Extension: extName, | |
| 1248 | + | ID: id, | |
| 1249 | + | Title: widget.Title, | |
| 1250 | + | Lines: append([]string(nil), widget.Lines...), | |
| 1251 | + | }) | |
| 1252 | + | } | |
| 1253 | + | } | |
| 1254 | + | return out | |
| 1255 | + | } | |
| 1256 | + | ||
| 1238 | 1257 | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |
| 1258 | + | return i.extensionChromeLinesAtLocked(cols, false) | |
| 1259 | + | } | |
| 1260 | + | ||
| 1261 | + | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |
| 1239 | 1262 | var out []string | |
| 1240 | 1263 | bodyWidth := cols - 2 | |
| 1241 | 1264 | if bodyWidth < 1 { | |
| @@ -1260,6 +1283,9 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |||
| 1260 | 1283 | } | |
| 1261 | 1284 | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |
| 1262 | 1285 | widget := i.extWidgets[extName][id] | |
| 1286 | + | if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar { | |
| 1287 | + | continue | |
| 1288 | + | } | |
| 1263 | 1289 | label := " [" + extName + "]" | |
| 1264 | 1290 | if widget.Title != "" { | |
| 1265 | 1291 | label += " " + widget.Title | |
| @@ -1560,20 +1586,31 @@ func (i *Interactive) redraw() { | |||
| 1560 | 1586 | i.mu.Lock() | |
| 1561 | 1587 | defer i.mu.Unlock() | |
| 1562 | 1588 | ||
| 1563 | - | cols, _ := i.cfg.Terminal.Size() | |
| 1564 | - | chat := i.cachedChatLocked(cols) | |
| 1589 | + | cols, rows := i.cfg.Terminal.Size() | |
| 1590 | + | mainCols := cols | |
| 1591 | + | rightBarWidgets := i.rightBarWidgetsLocked() | |
| 1592 | + | rightBarActive := false | |
| 1593 | + | var rightBarWidth int | |
| 1594 | + | if len(rightBarWidgets) > 0 { | |
| 1595 | + | if width, rail, ok := tui.RightBarColumns(cols); ok { | |
| 1596 | + | mainCols = width | |
| 1597 | + | rightBarWidth = rail | |
| 1598 | + | rightBarActive = true | |
| 1599 | + | } | |
| 1600 | + | } | |
| 1601 | + | chat := i.cachedChatLocked(mainCols) | |
| 1565 | 1602 | ||
| 1566 | 1603 | // Dialogs (login or model picker) render between chat and the editor. | |
| 1567 | 1604 | var dialog []string | |
| 1568 | 1605 | switch { | |
| 1569 | 1606 | case i.dialog.Active(): | |
| 1570 | - | dialog = i.dialog.Render(i.cfg.Theme, cols) | |
| 1607 | + | dialog = i.dialog.Render(i.cfg.Theme, mainCols) | |
| 1571 | 1608 | case i.modelDialog.Active(): | |
| 1572 | - | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | |
| 1609 | + | dialog = i.modelDialog.Render(i.cfg.Theme, mainCols) | |
| 1573 | 1610 | case i.llamaDialog.Active(): | |
| 1574 | - | dialog = i.llamaDialog.Render(i.cfg.Theme, cols) | |
| 1611 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols) | |
| 1575 | 1612 | case i.rescueDialog.Active(): | |
| 1576 | - | dialog = i.rescueDialog.Render(i.cfg.Theme, cols) | |
| 1613 | + | dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols) | |
| 1577 | 1614 | case i.sessionDialog.Active(): | |
| 1578 | 1615 | // Reserve rows for the editor (~3), status line (1-2), | |
| 1579 | 1616 | // dialog chrome (header + hint + rule + indicators, ~5), | |
| @@ -1585,36 +1622,36 @@ func (i *Interactive) redraw() { | |||
| 1585 | 1622 | avail = 3 | |
| 1586 | 1623 | } | |
| 1587 | 1624 | i.sessionDialog.MaxRows = avail | |
| 1588 | - | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | |
| 1625 | + | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) | |
| 1589 | 1626 | case i.subagentsDialog.Active(): | |
| 1590 | - | dialog = i.subagentsDialog.Render(i.cfg.Theme, cols) | |
| 1627 | + | dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols) | |
| 1591 | 1628 | case i.jumpDialog.Active(): | |
| 1592 | - | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | |
| 1629 | + | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) | |
| 1593 | 1630 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | |
| 1594 | - | dialog = i.extPanel.Render(i.cfg.Theme, cols) | |
| 1631 | + | dialog = i.extPanel.Render(i.cfg.Theme, mainCols) | |
| 1595 | 1632 | case i.confirmDialog.Active(): | |
| 1596 | 1633 | if i.btwDialog.Active() { | |
| 1597 | 1634 | // Keep the side-chat transcript and its live tool preview visible | |
| 1598 | 1635 | // above confirmation, matching how the main transcript remains | |
| 1599 | 1636 | // visible while --no-yolo waits for a decision. | |
| 1600 | - | dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog) | |
| 1637 | + | dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog) | |
| 1601 | 1638 | } else { | |
| 1602 | - | dialog = i.confirmDialog.Render(i.cfg.Theme, cols) | |
| 1639 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols) | |
| 1603 | 1640 | } | |
| 1604 | 1641 | case i.btwDialog.Active(): | |
| 1605 | - | dialog = i.btwDialog.Render(i.cfg.Theme, cols) | |
| 1642 | + | dialog = i.btwDialog.Render(i.cfg.Theme, mainCols) | |
| 1606 | 1643 | case i.skillsDialog.Active(): | |
| 1607 | - | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | |
| 1644 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols) | |
| 1608 | 1645 | case i.changelogDialog.Active(): | |
| 1609 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | |
| 1646 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) | |
| 1610 | 1647 | case i.logoutDialog.Active(): | |
| 1611 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, cols) | |
| 1648 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) | |
| 1612 | 1649 | case i.telegramDialog.Active(): | |
| 1613 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, cols) | |
| 1650 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) | |
| 1614 | 1651 | case i.settingsDialog.Active(): | |
| 1615 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, cols) | |
| 1652 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) | |
| 1616 | 1653 | case i.sessionOpsDialog.Active(): | |
| 1617 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | |
| 1654 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) | |
| 1618 | 1655 | case i.sessionTreeDialog.Active(): | |
| 1619 | 1656 | // Reserve rows for the editor, status, and tree chrome while using | |
| 1620 | 1657 | // the same budget for rendering and PageUp/PageDown movement. | |
| @@ -1624,7 +1661,7 @@ func (i *Interactive) redraw() { | |||
| 1624 | 1661 | avail = 3 | |
| 1625 | 1662 | } | |
| 1626 | 1663 | i.sessionTreeDialog.MaxRows = avail | |
| 1627 | - | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols) | |
| 1664 | + | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols) | |
| 1628 | 1665 | } | |
| 1629 | 1666 | if len(dialog) > 0 { | |
| 1630 | 1667 | dialog = padDialogFrame(dialog) | |
| @@ -1673,9 +1710,9 @@ func (i *Interactive) redraw() { | |||
| 1673 | 1710 | i.fileSuggest.SetCWD(i.cfg.CWD) | |
| 1674 | 1711 | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked()) | |
| 1675 | 1712 | if mainInputFocused && i.suggest.Active(currentInput) { | |
| 1676 | - | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | |
| 1713 | + | suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols) | |
| 1677 | 1714 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | |
| 1678 | - | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols) | |
| 1715 | + | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols) | |
| 1679 | 1716 | } | |
| 1680 | 1717 | ||
| 1681 | 1718 | // Detect overlay close (any dialog or slash/file suggestion popup | |
| @@ -1749,7 +1786,7 @@ func (i *Interactive) redraw() { | |||
| 1749 | 1786 | ContextMax: ctxMax, | |
| 1750 | 1787 | AutoCompacting: i.autoCompacting, | |
| 1751 | 1788 | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | |
| 1752 | - | Cols: cols, | |
| 1789 | + | Cols: mainCols, | |
| 1753 | 1790 | }) | |
| 1754 | 1791 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | |
| 1755 | 1792 | if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock { | |
| @@ -1757,7 +1794,7 @@ func (i *Interactive) redraw() { | |||
| 1757 | 1794 | } else { | |
| 1758 | 1795 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | |
| 1759 | 1796 | } | |
| 1760 | - | edLines, curR, curC := i.ed.Render(cols) | |
| 1797 | + | edLines, curR, curC := i.ed.Render(mainCols) | |
| 1761 | 1798 | var workingLines []string | |
| 1762 | 1799 | if busyPrefix != "" && !workingWithStatus { | |
| 1763 | 1800 | workingLines = []string{" " + busyPrefix} | |
| @@ -1766,10 +1803,10 @@ func (i *Interactive) redraw() { | |||
| 1766 | 1803 | inputCursorColOffset := 0 | |
| 1767 | 1804 | switch inputStyle { | |
| 1768 | 1805 | case tui.InputStyleLines: | |
| 1769 | - | edLines = tui.InputLines(i.cfg.Theme, edLines, cols) | |
| 1806 | + | edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols) | |
| 1770 | 1807 | inputCursorOffset = 1 | |
| 1771 | 1808 | case tui.InputStyleBlock: | |
| 1772 | - | edLines = tui.InputBlock(i.cfg.Theme, edLines, cols) | |
| 1809 | + | edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols) | |
| 1773 | 1810 | inputCursorOffset = 1 | |
| 1774 | 1811 | inputCursorColOffset = 2 | |
| 1775 | 1812 | } | |
| @@ -1786,7 +1823,7 @@ func (i *Interactive) redraw() { | |||
| 1786 | 1823 | queue = append(queue, "") | |
| 1787 | 1824 | for _, q := range queued { | |
| 1788 | 1825 | label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ") | |
| 1789 | - | text := truncateLine(q, cols-17) | |
| 1826 | + | text := truncateLine(q, mainCols-17) | |
| 1790 | 1827 | queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) | |
| 1791 | 1828 | } | |
| 1792 | 1829 | // Hint row, rendered in the same muted tone as the model | |
| @@ -1797,7 +1834,7 @@ func (i *Interactive) redraw() { | |||
| 1797 | 1834 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | |
| 1798 | 1835 | } | |
| 1799 | 1836 | ||
| 1800 | - | extensionLines := i.extensionChromeLinesLocked(cols) | |
| 1837 | + | extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive) | |
| 1801 | 1838 | ||
| 1802 | 1839 | // Bottom-sticky sections (always visible, never scroll). Each | |
| 1803 | 1840 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | |
| @@ -1861,8 +1898,12 @@ func (i *Interactive) redraw() { | |||
| 1861 | 1898 | } | |
| 1862 | 1899 | } | |
| 1863 | 1900 | ||
| 1864 | - | _, rows := i.cfg.Terminal.Size() | |
| 1865 | 1901 | chatRows := rows - len(bottom) | |
| 1902 | + | if rightBarActive { | |
| 1903 | + | // DrawRightBar keeps the renderer-owned bottom margin inside the | |
| 1904 | + | // fixed frame, so reserve that row before selecting visible chat. | |
| 1905 | + | chatRows-- | |
| 1906 | + | } | |
| 1866 | 1907 | if chatRows < 1 { | |
| 1867 | 1908 | chatRows = 1 | |
| 1868 | 1909 | } | |
| @@ -1890,12 +1931,12 @@ func (i *Interactive) redraw() { | |||
| 1890 | 1931 | // let a shrinking chatRows pull the window toward the tail, which | |
| 1891 | 1932 | // read as the viewport jumping to the bottom whenever the agent | |
| 1892 | 1933 | // streamed text or a tool call grew the bottom band. | |
| 1893 | - | if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 { | |
| 1934 | + | if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 { | |
| 1894 | 1935 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | |
| 1895 | 1936 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | |
| 1896 | 1937 | } | |
| 1897 | 1938 | i.prevChatLen = len(chat) | |
| 1898 | - | i.prevChatCols = cols | |
| 1939 | + | i.prevChatCols = mainCols | |
| 1899 | 1940 | i.prevChatRows = chatRows | |
| 1900 | 1941 | ||
| 1901 | 1942 | // Apply scroll offset to the chat slice. | |
| @@ -1917,7 +1958,7 @@ func (i *Interactive) redraw() { | |||
| 1917 | 1958 | i.view.TailLimit = 0 // unbounded | |
| 1918 | 1959 | } | |
| 1919 | 1960 | i.chatCacheValid = false | |
| 1920 | - | chat = i.cachedChatLocked(cols) | |
| 1961 | + | chat = i.cachedChatLocked(mainCols) | |
| 1921 | 1962 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | |
| 1922 | 1963 | chat = chat[:len(chat)-1] | |
| 1923 | 1964 | } | |
| @@ -1933,7 +1974,7 @@ func (i *Interactive) redraw() { | |||
| 1933 | 1974 | i.scrollOffset += grew | |
| 1934 | 1975 | } | |
| 1935 | 1976 | i.prevChatLen = len(chat) | |
| 1936 | - | i.prevChatCols = cols | |
| 1977 | + | i.prevChatCols = mainCols | |
| 1937 | 1978 | maxOffset = len(chat) - chatRows | |
| 1938 | 1979 | if maxOffset < 0 { | |
| 1939 | 1980 | maxOffset = 0 | |
| @@ -2018,13 +2059,13 @@ func (i *Interactive) redraw() { | |||
| 2018 | 2059 | cursorRow := inputStartRow + inputCursorOffset + curR | |
| 2019 | 2060 | cursorCol := curC + inputCursorColOffset | |
| 2020 | 2061 | if i.btwDialog.Active() { | |
| 2021 | - | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | |
| 2062 | + | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { | |
| 2022 | 2063 | cursorRow = dialogLead + r | |
| 2023 | 2064 | cursorCol = c | |
| 2024 | 2065 | } | |
| 2025 | 2066 | } | |
| 2026 | 2067 | if i.dialog.Active() { | |
| 2027 | - | if r, c := i.dialog.CursorPos(cols); r >= 0 { | |
| 2068 | + | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { | |
| 2028 | 2069 | cursorRow = dialogLead + r | |
| 2029 | 2070 | cursorCol = c | |
| 2030 | 2071 | } | |
| @@ -2051,7 +2092,7 @@ func (i *Interactive) redraw() { | |||
| 2051 | 2092 | } | |
| 2052 | 2093 | } | |
| 2053 | 2094 | if i.subagentsDialog.Active() { | |
| 2054 | - | if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 { | |
| 2095 | + | if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 { | |
| 2055 | 2096 | cursorRow = dialogLead + r | |
| 2056 | 2097 | cursorCol = c | |
| 2057 | 2098 | } else { | |
| @@ -2067,8 +2108,13 @@ func (i *Interactive) redraw() { | |||
| 2067 | 2108 | cursorRow = -1 | |
| 2068 | 2109 | cursorCol = 0 | |
| 2069 | 2110 | } | |
| 2070 | - | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2071 | - | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 2111 | + | if rightBarActive { | |
| 2112 | + | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) | |
| 2113 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |
| 2114 | + | } else { | |
| 2115 | + | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |
| 2116 | + | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |
| 2117 | + | } | |
| 2072 | 2118 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | |
| 2073 | 2119 | alert := *i.pendingAlert | |
| 2074 | 2120 | i.pendingAlert = nil | |
| @@ -3748,6 +3794,7 @@ func (i *Interactive) SetWidget(extName, id, position, title string, lines []str | |||
| 3748 | 3794 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | |
| 3749 | 3795 | return | |
| 3750 | 3796 | } | |
| 3797 | + | position = extproto.NormalizeWidgetPosition(position) | |
| 3751 | 3798 | i.mu.Lock() | |
| 3752 | 3799 | if i.extWidgets == nil { | |
| 3753 | 3800 | i.extWidgets = map[string]map[string]extensionWidget{} | |
| @@ -3777,6 +3824,45 @@ func (i *Interactive) ClearWidget(extName, id string) { | |||
| 3777 | 3824 | i.invalidate() | |
| 3778 | 3825 | } | |
| 3779 | 3826 | ||
| 3827 | + | // ClearExtensionChrome removes every persistent UI item owned by an | |
| 3828 | + | // extension that has exited. This keeps crashes, disable/reload operations, | |
| 3829 | + | // and startup failures from leaving stale widgets beside the transcript. | |
| 3830 | + | func (i *Interactive) ClearExtensionChrome(extName string) { | |
| 3831 | + | if strings.TrimSpace(extName) == "" { | |
| 3832 | + | return | |
| 3833 | + | } | |
| 3834 | + | marker := "[" + extName + "] " | |
| 3835 | + | i.mu.Lock() | |
| 3836 | + | changed := false | |
| 3837 | + | if _, ok := i.extStatuses[extName]; ok { | |
| 3838 | + | delete(i.extStatuses, extName) | |
| 3839 | + | changed = true | |
| 3840 | + | } | |
| 3841 | + | if _, ok := i.extWidgets[extName]; ok { | |
| 3842 | + | delete(i.extWidgets, extName) | |
| 3843 | + | changed = true | |
| 3844 | + | } | |
| 3845 | + | if len(i.extNotes) > 0 { | |
| 3846 | + | kept := i.extNotes[:0:0] | |
| 3847 | + | for _, line := range i.extNotes { | |
| 3848 | + | if strings.Contains(line, marker) { | |
| 3849 | + | changed = true | |
| 3850 | + | continue | |
| 3851 | + | } | |
| 3852 | + | kept = append(kept, line) | |
| 3853 | + | } | |
| 3854 | + | i.extNotes = kept | |
| 3855 | + | } | |
| 3856 | + | if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName { | |
| 3857 | + | i.extPanel.Close() | |
| 3858 | + | changed = true | |
| 3859 | + | } | |
| 3860 | + | i.mu.Unlock() | |
| 3861 | + | if changed { | |
| 3862 | + | i.invalidate() | |
| 3863 | + | } | |
| 3864 | + | } | |
| 3865 | + | ||
| 3780 | 3866 | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | |
| 3781 | 3867 | i.mu.Lock() | |
| 3782 | 3868 | defer i.mu.Unlock() | |
| @@ -1235,7 +1235,30 @@ func (i *Interactive) extensionWidgetsKeyLocked() string { | |||||
| 1235 | const maxExtensionWidgetRows = 12 | 1235 | const maxExtensionWidgetRows = 12 | ||
| 1236 | const maxExtensionStatusRows = 6 | 1236 | const maxExtensionStatusRows = 6 | ||
| 1237 | 1237 | ||||
| 1238 | + | func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget { | |||
| 1239 | + | var out []tui.RightBarWidget | |||
| 1240 | + | for _, extName := range sortedNestedOuterKeys(i.extWidgets) { | |||
| 1241 | + | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | |||
| 1242 | + | widget := i.extWidgets[extName][id] | |||
| 1243 | + | if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar { | |||
| 1244 | + | continue | |||
| 1245 | + | } | |||
| 1246 | + | out = append(out, tui.RightBarWidget{ | |||
| 1247 | + | Extension: extName, | |||
| 1248 | + | ID: id, | |||
| 1249 | + | Title: widget.Title, | |||
| 1250 | + | Lines: append([]string(nil), widget.Lines...), | |||
| 1251 | + | }) | |||
| 1252 | + | } | |||
| 1253 | + | } | |||
| 1254 | + | return out | |||
| 1255 | + | } | |||
| 1256 | + | ||||
| 1238 | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | 1257 | func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | ||
| 1258 | + | return i.extensionChromeLinesAtLocked(cols, false) | |||
| 1259 | + | } | |||
| 1260 | + | ||||
| 1261 | + | func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string { | |||
| 1239 | var out []string | 1262 | var out []string | ||
| 1240 | bodyWidth := cols - 2 | 1263 | bodyWidth := cols - 2 | ||
| 1241 | if bodyWidth < 1 { | 1264 | if bodyWidth < 1 { | ||
| @@ -1260,6 +1283,9 @@ func (i *Interactive) extensionChromeLinesLocked(cols int) []string { | |||||
| 1260 | } | 1283 | } | ||
| 1261 | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | 1284 | for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) { | ||
| 1262 | widget := i.extWidgets[extName][id] | 1285 | widget := i.extWidgets[extName][id] | ||
| 1286 | + | if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar { | |||
| 1287 | + | continue | |||
| 1288 | + | } | |||
| 1263 | label := " [" + extName + "]" | 1289 | label := " [" + extName + "]" | ||
| 1264 | if widget.Title != "" { | 1290 | if widget.Title != "" { | ||
| 1265 | label += " " + widget.Title | 1291 | label += " " + widget.Title | ||
| @@ -1560,20 +1586,31 @@ func (i *Interactive) redraw() { | |||||
| 1560 | i.mu.Lock() | 1586 | i.mu.Lock() | ||
| 1561 | defer i.mu.Unlock() | 1587 | defer i.mu.Unlock() | ||
| 1562 | 1588 | ||||
| 1563 | - | cols, _ := i.cfg.Terminal.Size() | 1589 | + | cols, rows := i.cfg.Terminal.Size() |
| 1564 | - | chat := i.cachedChatLocked(cols) | 1590 | + | mainCols := cols |
| 1591 | + | rightBarWidgets := i.rightBarWidgetsLocked() | |||
| 1592 | + | rightBarActive := false | |||
| 1593 | + | var rightBarWidth int | |||
| 1594 | + | if len(rightBarWidgets) > 0 { | |||
| 1595 | + | if width, rail, ok := tui.RightBarColumns(cols); ok { | |||
| 1596 | + | mainCols = width | |||
| 1597 | + | rightBarWidth = rail | |||
| 1598 | + | rightBarActive = true | |||
| 1599 | + | } | |||
| 1600 | + | } | |||
| 1601 | + | chat := i.cachedChatLocked(mainCols) | |||
| 1565 | 1602 | ||||
| 1566 | // Dialogs (login or model picker) render between chat and the editor. | 1603 | // Dialogs (login or model picker) render between chat and the editor. | ||
| 1567 | var dialog []string | 1604 | var dialog []string | ||
| 1568 | switch { | 1605 | switch { | ||
| 1569 | case i.dialog.Active(): | 1606 | case i.dialog.Active(): | ||
| 1570 | - | dialog = i.dialog.Render(i.cfg.Theme, cols) | 1607 | + | dialog = i.dialog.Render(i.cfg.Theme, mainCols) |
| 1571 | case i.modelDialog.Active(): | 1608 | case i.modelDialog.Active(): | ||
| 1572 | - | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | 1609 | + | dialog = i.modelDialog.Render(i.cfg.Theme, mainCols) |
| 1573 | case i.llamaDialog.Active(): | 1610 | case i.llamaDialog.Active(): | ||
| 1574 | - | dialog = i.llamaDialog.Render(i.cfg.Theme, cols) | 1611 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols) |
| 1575 | case i.rescueDialog.Active(): | 1612 | case i.rescueDialog.Active(): | ||
| 1576 | - | dialog = i.rescueDialog.Render(i.cfg.Theme, cols) | 1613 | + | dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols) |
| 1577 | case i.sessionDialog.Active(): | 1614 | case i.sessionDialog.Active(): | ||
| 1578 | // Reserve rows for the editor (~3), status line (1-2), | 1615 | // Reserve rows for the editor (~3), status line (1-2), | ||
| 1579 | // dialog chrome (header + hint + rule + indicators, ~5), | 1616 | // dialog chrome (header + hint + rule + indicators, ~5), | ||
| @@ -1585,36 +1622,36 @@ func (i *Interactive) redraw() { | |||||
| 1585 | avail = 3 | 1622 | avail = 3 | ||
| 1586 | } | 1623 | } | ||
| 1587 | i.sessionDialog.MaxRows = avail | 1624 | i.sessionDialog.MaxRows = avail | ||
| 1588 | - | dialog = i.sessionDialog.Render(i.cfg.Theme, cols) | 1625 | + | dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols) |
| 1589 | case i.subagentsDialog.Active(): | 1626 | case i.subagentsDialog.Active(): | ||
| 1590 | - | dialog = i.subagentsDialog.Render(i.cfg.Theme, cols) | 1627 | + | dialog = i.subagentsDialog.Render(i.cfg.Theme, mainCols) |
| 1591 | case i.jumpDialog.Active(): | 1628 | case i.jumpDialog.Active(): | ||
| 1592 | - | dialog = i.jumpDialog.Render(i.cfg.Theme, cols) | 1629 | + | dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols) |
| 1593 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | 1630 | case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()): | ||
| 1594 | - | dialog = i.extPanel.Render(i.cfg.Theme, cols) | 1631 | + | dialog = i.extPanel.Render(i.cfg.Theme, mainCols) |
| 1595 | case i.confirmDialog.Active(): | 1632 | case i.confirmDialog.Active(): | ||
| 1596 | if i.btwDialog.Active() { | 1633 | if i.btwDialog.Active() { | ||
| 1597 | // Keep the side-chat transcript and its live tool preview visible | 1634 | // Keep the side-chat transcript and its live tool preview visible | ||
| 1598 | // above confirmation, matching how the main transcript remains | 1635 | // above confirmation, matching how the main transcript remains | ||
| 1599 | // visible while --no-yolo waits for a decision. | 1636 | // visible while --no-yolo waits for a decision. | ||
| 1600 | - | dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog) | 1637 | + | dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog) |
| 1601 | } else { | 1638 | } else { | ||
| 1602 | - | dialog = i.confirmDialog.Render(i.cfg.Theme, cols) | 1639 | + | dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols) |
| 1603 | } | 1640 | } | ||
| 1604 | case i.btwDialog.Active(): | 1641 | case i.btwDialog.Active(): | ||
| 1605 | - | dialog = i.btwDialog.Render(i.cfg.Theme, cols) | 1642 | + | dialog = i.btwDialog.Render(i.cfg.Theme, mainCols) |
| 1606 | case i.skillsDialog.Active(): | 1643 | case i.skillsDialog.Active(): | ||
| 1607 | - | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | 1644 | + | dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols) |
| 1608 | case i.changelogDialog.Active(): | 1645 | case i.changelogDialog.Active(): | ||
| 1609 | - | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | 1646 | + | dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols) |
| 1610 | case i.logoutDialog.Active(): | 1647 | case i.logoutDialog.Active(): | ||
| 1611 | - | dialog = i.logoutDialog.Render(i.cfg.Theme, cols) | 1648 | + | dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols) |
| 1612 | case i.telegramDialog.Active(): | 1649 | case i.telegramDialog.Active(): | ||
| 1613 | - | dialog = i.telegramDialog.Render(i.cfg.Theme, cols) | 1650 | + | dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols) |
| 1614 | case i.settingsDialog.Active(): | 1651 | case i.settingsDialog.Active(): | ||
| 1615 | - | dialog = i.settingsDialog.Render(i.cfg.Theme, cols) | 1652 | + | dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols) |
| 1616 | case i.sessionOpsDialog.Active(): | 1653 | case i.sessionOpsDialog.Active(): | ||
| 1617 | - | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) | 1654 | + | dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols) |
| 1618 | case i.sessionTreeDialog.Active(): | 1655 | case i.sessionTreeDialog.Active(): | ||
| 1619 | // Reserve rows for the editor, status, and tree chrome while using | 1656 | // Reserve rows for the editor, status, and tree chrome while using | ||
| 1620 | // the same budget for rendering and PageUp/PageDown movement. | 1657 | // the same budget for rendering and PageUp/PageDown movement. | ||
| @@ -1624,7 +1661,7 @@ func (i *Interactive) redraw() { | |||||
| 1624 | avail = 3 | 1661 | avail = 3 | ||
| 1625 | } | 1662 | } | ||
| 1626 | i.sessionTreeDialog.MaxRows = avail | 1663 | i.sessionTreeDialog.MaxRows = avail | ||
| 1627 | - | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, cols) | 1664 | + | dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols) |
| 1628 | } | 1665 | } | ||
| 1629 | if len(dialog) > 0 { | 1666 | if len(dialog) > 0 { | ||
| 1630 | dialog = padDialogFrame(dialog) | 1667 | dialog = padDialogFrame(dialog) | ||
| @@ -1673,9 +1710,9 @@ func (i *Interactive) redraw() { | |||||
| 1673 | i.fileSuggest.SetCWD(i.cfg.CWD) | 1710 | i.fileSuggest.SetCWD(i.cfg.CWD) | ||
| 1674 | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked()) | 1711 | mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked()) | ||
| 1675 | if mainInputFocused && i.suggest.Active(currentInput) { | 1712 | if mainInputFocused && i.suggest.Active(currentInput) { | ||
| 1676 | - | suggest = i.suggest.Render(currentInput, i.cfg.Theme, cols) | 1713 | + | suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols) |
| 1677 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | 1714 | } else if mainInputFocused && i.fileSuggest.Active(currentInput) { | ||
| 1678 | - | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, cols) | 1715 | + | suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols) |
| 1679 | } | 1716 | } | ||
| 1680 | 1717 | ||||
| 1681 | // Detect overlay close (any dialog or slash/file suggestion popup | 1718 | // Detect overlay close (any dialog or slash/file suggestion popup | ||
| @@ -1749,7 +1786,7 @@ func (i *Interactive) redraw() { | |||||
| 1749 | ContextMax: ctxMax, | 1786 | ContextMax: ctxMax, | ||
| 1750 | AutoCompacting: i.autoCompacting, | 1787 | AutoCompacting: i.autoCompacting, | ||
| 1751 | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | 1788 | Telegram: i.telegramBridge != nil && i.telegramBridge.Active(), | ||
| 1752 | - | Cols: cols, | 1789 | + | Cols: mainCols, |
| 1753 | }) | 1790 | }) | ||
| 1754 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | 1791 | inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) | ||
| 1755 | if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock { | 1792 | if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock { | ||
| @@ -1757,7 +1794,7 @@ func (i *Interactive) redraw() { | |||||
| 1757 | } else { | 1794 | } else { | ||
| 1758 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | 1795 | i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent) | ||
| 1759 | } | 1796 | } | ||
| 1760 | - | edLines, curR, curC := i.ed.Render(cols) | 1797 | + | edLines, curR, curC := i.ed.Render(mainCols) |
| 1761 | var workingLines []string | 1798 | var workingLines []string | ||
| 1762 | if busyPrefix != "" && !workingWithStatus { | 1799 | if busyPrefix != "" && !workingWithStatus { | ||
| 1763 | workingLines = []string{" " + busyPrefix} | 1800 | workingLines = []string{" " + busyPrefix} | ||
| @@ -1766,10 +1803,10 @@ func (i *Interactive) redraw() { | |||||
| 1766 | inputCursorColOffset := 0 | 1803 | inputCursorColOffset := 0 | ||
| 1767 | switch inputStyle { | 1804 | switch inputStyle { | ||
| 1768 | case tui.InputStyleLines: | 1805 | case tui.InputStyleLines: | ||
| 1769 | - | edLines = tui.InputLines(i.cfg.Theme, edLines, cols) | 1806 | + | edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols) |
| 1770 | inputCursorOffset = 1 | 1807 | inputCursorOffset = 1 | ||
| 1771 | case tui.InputStyleBlock: | 1808 | case tui.InputStyleBlock: | ||
| 1772 | - | edLines = tui.InputBlock(i.cfg.Theme, edLines, cols) | 1809 | + | edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols) |
| 1773 | inputCursorOffset = 1 | 1810 | inputCursorOffset = 1 | ||
| 1774 | inputCursorColOffset = 2 | 1811 | inputCursorColOffset = 2 | ||
| 1775 | } | 1812 | } | ||
| @@ -1786,7 +1823,7 @@ func (i *Interactive) redraw() { | |||||
| 1786 | queue = append(queue, "") | 1823 | queue = append(queue, "") | ||
| 1787 | for _, q := range queued { | 1824 | for _, q := range queued { | ||
| 1788 | label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ") | 1825 | label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, " sliding in: ") | ||
| 1789 | - | text := truncateLine(q, cols-17) | 1826 | + | text := truncateLine(q, mainCols-17) |
| 1790 | queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) | 1827 | queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) | ||
| 1791 | } | 1828 | } | ||
| 1792 | // Hint row, rendered in the same muted tone as the model | 1829 | // Hint row, rendered in the same muted tone as the model | ||
| @@ -1797,7 +1834,7 @@ func (i *Interactive) redraw() { | |||||
| 1797 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | 1834 | queue = append(queue, i.cfg.Theme.FG256(i.cfg.Theme.Muted, hint)) | ||
| 1798 | } | 1835 | } | ||
| 1799 | 1836 | ||||
| 1800 | - | extensionLines := i.extensionChromeLinesLocked(cols) | 1837 | + | extensionLines := i.extensionChromeLinesAtLocked(mainCols, rightBarActive) |
| 1801 | 1838 | ||||
| 1802 | // Bottom-sticky sections (always visible, never scroll). Each | 1839 | // Bottom-sticky sections (always visible, never scroll). Each | ||
| 1803 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | 1840 | // non-empty subsection (dialog, suggest popup, sliding-in queue) | ||
| @@ -1861,8 +1898,12 @@ func (i *Interactive) redraw() { | |||||
| 1861 | } | 1898 | } | ||
| 1862 | } | 1899 | } | ||
| 1863 | 1900 | ||||
| 1864 | - | _, rows := i.cfg.Terminal.Size() | |||
| 1865 | chatRows := rows - len(bottom) | 1901 | chatRows := rows - len(bottom) | ||
| 1902 | + | if rightBarActive { | |||
| 1903 | + | // DrawRightBar keeps the renderer-owned bottom margin inside the | |||
| 1904 | + | // fixed frame, so reserve that row before selecting visible chat. | |||
| 1905 | + | chatRows-- | |||
| 1906 | + | } | |||
| 1866 | if chatRows < 1 { | 1907 | if chatRows < 1 { | ||
| 1867 | chatRows = 1 | 1908 | chatRows = 1 | ||
| 1868 | } | 1909 | } | ||
| @@ -1890,12 +1931,12 @@ func (i *Interactive) redraw() { | |||||
| 1890 | // let a shrinking chatRows pull the window toward the tail, which | 1931 | // let a shrinking chatRows pull the window toward the tail, which | ||
| 1891 | // read as the viewport jumping to the bottom whenever the agent | 1932 | // read as the viewport jumping to the bottom whenever the agent | ||
| 1892 | // streamed text or a tool call grew the bottom band. | 1933 | // streamed text or a tool call grew the bottom band. | ||
| 1893 | - | if i.scrollOffset > 0 && i.prevChatCols == cols && i.prevChatLen > 0 { | 1934 | + | if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 { |
| 1894 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | 1935 | i.scrollOffset = anchorScrollOffset(i.scrollOffset, | ||
| 1895 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | 1936 | i.prevChatLen, len(chat), i.prevChatRows, chatRows) | ||
| 1896 | } | 1937 | } | ||
| 1897 | i.prevChatLen = len(chat) | 1938 | i.prevChatLen = len(chat) | ||
| 1898 | - | i.prevChatCols = cols | 1939 | + | i.prevChatCols = mainCols |
| 1899 | i.prevChatRows = chatRows | 1940 | i.prevChatRows = chatRows | ||
| 1900 | 1941 | ||||
| 1901 | // Apply scroll offset to the chat slice. | 1942 | // Apply scroll offset to the chat slice. | ||
| @@ -1917,7 +1958,7 @@ func (i *Interactive) redraw() { | |||||
| 1917 | i.view.TailLimit = 0 // unbounded | 1958 | i.view.TailLimit = 0 // unbounded | ||
| 1918 | } | 1959 | } | ||
| 1919 | i.chatCacheValid = false | 1960 | i.chatCacheValid = false | ||
| 1920 | - | chat = i.cachedChatLocked(cols) | 1961 | + | chat = i.cachedChatLocked(mainCols) |
| 1921 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | 1962 | for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" { | ||
| 1922 | chat = chat[:len(chat)-1] | 1963 | chat = chat[:len(chat)-1] | ||
| 1923 | } | 1964 | } | ||
| @@ -1933,7 +1974,7 @@ func (i *Interactive) redraw() { | |||||
| 1933 | i.scrollOffset += grew | 1974 | i.scrollOffset += grew | ||
| 1934 | } | 1975 | } | ||
| 1935 | i.prevChatLen = len(chat) | 1976 | i.prevChatLen = len(chat) | ||
| 1936 | - | i.prevChatCols = cols | 1977 | + | i.prevChatCols = mainCols |
| 1937 | maxOffset = len(chat) - chatRows | 1978 | maxOffset = len(chat) - chatRows | ||
| 1938 | if maxOffset < 0 { | 1979 | if maxOffset < 0 { | ||
| 1939 | maxOffset = 0 | 1980 | maxOffset = 0 | ||
| @@ -2018,13 +2059,13 @@ func (i *Interactive) redraw() { | |||||
| 2018 | cursorRow := inputStartRow + inputCursorOffset + curR | 2059 | cursorRow := inputStartRow + inputCursorOffset + curR | ||
| 2019 | cursorCol := curC + inputCursorColOffset | 2060 | cursorCol := curC + inputCursorColOffset | ||
| 2020 | if i.btwDialog.Active() { | 2061 | if i.btwDialog.Active() { | ||
| 2021 | - | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | 2062 | + | if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 { |
| 2022 | cursorRow = dialogLead + r | 2063 | cursorRow = dialogLead + r | ||
| 2023 | cursorCol = c | 2064 | cursorCol = c | ||
| 2024 | } | 2065 | } | ||
| 2025 | } | 2066 | } | ||
| 2026 | if i.dialog.Active() { | 2067 | if i.dialog.Active() { | ||
| 2027 | - | if r, c := i.dialog.CursorPos(cols); r >= 0 { | 2068 | + | if r, c := i.dialog.CursorPos(mainCols); r >= 0 { |
| 2028 | cursorRow = dialogLead + r | 2069 | cursorRow = dialogLead + r | ||
| 2029 | cursorCol = c | 2070 | cursorCol = c | ||
| 2030 | } | 2071 | } | ||
| @@ -2051,7 +2092,7 @@ func (i *Interactive) redraw() { | |||||
| 2051 | } | 2092 | } | ||
| 2052 | } | 2093 | } | ||
| 2053 | if i.subagentsDialog.Active() { | 2094 | if i.subagentsDialog.Active() { | ||
| 2054 | - | if r, c := i.subagentsDialog.CursorPos(cols); r >= 0 { | 2095 | + | if r, c := i.subagentsDialog.CursorPos(mainCols); r >= 0 { |
| 2055 | cursorRow = dialogLead + r | 2096 | cursorRow = dialogLead + r | ||
| 2056 | cursorCol = c | 2097 | cursorCol = c | ||
| 2057 | } else { | 2098 | } else { | ||
| @@ -2067,8 +2108,13 @@ func (i *Interactive) redraw() { | |||||
| 2067 | cursorRow = -1 | 2108 | cursorRow = -1 | ||
| 2068 | cursorCol = 0 | 2109 | cursorCol = 0 | ||
| 2069 | } | 2110 | } | ||
| 2070 | - | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | 2111 | + | if rightBarActive { |
| 2071 | - | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | 2112 | + | rightBar := tui.RenderRightBar(i.cfg.Theme, rightBarWidgets, rightBarWidth, rows) |
| 2113 | + | i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol) | |||
| 2114 | + | } else { | |||
| 2115 | + | _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport. | |||
| 2116 | + | i.rend.DrawLog(chat, bottom, cursorRow, cursorCol) | |||
| 2117 | + | } | |||
| 2072 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | 2118 | if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 { | ||
| 2073 | alert := *i.pendingAlert | 2119 | alert := *i.pendingAlert | ||
| 2074 | i.pendingAlert = nil | 2120 | i.pendingAlert = nil | ||
| @@ -3748,6 +3794,7 @@ func (i *Interactive) SetWidget(extName, id, position, title string, lines []str | |||||
| 3748 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | 3794 | if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" { | ||
| 3749 | return | 3795 | return | ||
| 3750 | } | 3796 | } | ||
| 3797 | + | position = extproto.NormalizeWidgetPosition(position) | |||
| 3751 | i.mu.Lock() | 3798 | i.mu.Lock() | ||
| 3752 | if i.extWidgets == nil { | 3799 | if i.extWidgets == nil { | ||
| 3753 | i.extWidgets = map[string]map[string]extensionWidget{} | 3800 | i.extWidgets = map[string]map[string]extensionWidget{} | ||
| @@ -3777,6 +3824,45 @@ func (i *Interactive) ClearWidget(extName, id string) { | |||||
| 3777 | i.invalidate() | 3824 | i.invalidate() | ||
| 3778 | } | 3825 | } | ||
| 3779 | 3826 | ||||
| 3827 | + | // ClearExtensionChrome removes every persistent UI item owned by an | |||
| 3828 | + | // extension that has exited. This keeps crashes, disable/reload operations, | |||
| 3829 | + | // and startup failures from leaving stale widgets beside the transcript. | |||
| 3830 | + | func (i *Interactive) ClearExtensionChrome(extName string) { | |||
| 3831 | + | if strings.TrimSpace(extName) == "" { | |||
| 3832 | + | return | |||
| 3833 | + | } | |||
| 3834 | + | marker := "[" + extName + "] " | |||
| 3835 | + | i.mu.Lock() | |||
| 3836 | + | changed := false | |||
| 3837 | + | if _, ok := i.extStatuses[extName]; ok { | |||
| 3838 | + | delete(i.extStatuses, extName) | |||
| 3839 | + | changed = true | |||
| 3840 | + | } | |||
| 3841 | + | if _, ok := i.extWidgets[extName]; ok { | |||
| 3842 | + | delete(i.extWidgets, extName) | |||
| 3843 | + | changed = true | |||
| 3844 | + | } | |||
| 3845 | + | if len(i.extNotes) > 0 { | |||
| 3846 | + | kept := i.extNotes[:0:0] | |||
| 3847 | + | for _, line := range i.extNotes { | |||
| 3848 | + | if strings.Contains(line, marker) { | |||
| 3849 | + | changed = true | |||
| 3850 | + | continue | |||
| 3851 | + | } | |||
| 3852 | + | kept = append(kept, line) | |||
| 3853 | + | } | |||
| 3854 | + | i.extNotes = kept | |||
| 3855 | + | } | |||
| 3856 | + | if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName { | |||
| 3857 | + | i.extPanel.Close() | |||
| 3858 | + | changed = true | |||
| 3859 | + | } | |||
| 3860 | + | i.mu.Unlock() | |||
| 3861 | + | if changed { | |||
| 3862 | + | i.invalidate() | |||
| 3863 | + | } | |||
| 3864 | + | } | |||
| 3865 | + | ||||
| 3780 | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | 3866 | func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) { | ||
| 3781 | i.mu.Lock() | 3867 | i.mu.Lock() | ||
| 3782 | defer i.mu.Unlock() | 3868 | defer i.mu.Unlock() | ||
M packages/agent/rpc.go +8 -0
| @@ -200,6 +200,14 @@ func (h *rpcExtHooks) ClearWidget(extName, id string) { | |||
| 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 | + | } | |||