@pellux/goodvibes-tui 0.18.19 → 0.18.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-rendering.ts +20 -6
  5. package/src/input/commands/session.ts +0 -1
  6. package/src/input/feed-context-factory.ts +236 -0
  7. package/src/input/handler-feed-routes.ts +10 -0
  8. package/src/input/handler-feed.ts +44 -6
  9. package/src/input/handler-shortcuts.ts +138 -125
  10. package/src/input/handler.ts +121 -119
  11. package/src/input/keybindings.ts +30 -0
  12. package/src/panels/approval-panel.ts +54 -74
  13. package/src/panels/automation-control-panel.ts +119 -161
  14. package/src/panels/base-panel.ts +71 -0
  15. package/src/panels/communication-panel.ts +68 -107
  16. package/src/panels/confirm-state.ts +61 -0
  17. package/src/panels/control-plane-panel.ts +116 -172
  18. package/src/panels/git-panel.ts +9 -0
  19. package/src/panels/hooks-panel.ts +101 -138
  20. package/src/panels/incident-review-panel.ts +55 -107
  21. package/src/panels/knowledge-panel.ts +63 -14
  22. package/src/panels/local-auth-panel.ts +76 -93
  23. package/src/panels/marketplace-panel.ts +19 -12
  24. package/src/panels/mcp-panel.ts +108 -155
  25. package/src/panels/ops-control-panel.ts +50 -85
  26. package/src/panels/panel-manager.ts +22 -2
  27. package/src/panels/plugins-panel.ts +36 -60
  28. package/src/panels/routes-panel.ts +89 -141
  29. package/src/panels/scrollable-list-panel.ts +71 -16
  30. package/src/panels/security-panel.ts +101 -137
  31. package/src/panels/services-panel.ts +58 -102
  32. package/src/panels/settings-sync-panel.ts +76 -122
  33. package/src/panels/skills-panel.ts +44 -0
  34. package/src/panels/subscription-panel.ts +69 -80
  35. package/src/panels/tasks-panel.ts +129 -179
  36. package/src/panels/watchers-panel.ts +88 -137
  37. package/src/renderer/buffer.ts +11 -0
  38. package/src/renderer/diff.ts +8 -0
  39. package/src/renderer/help-overlay.ts +37 -28
  40. package/src/renderer/markdown.ts +3 -145
  41. package/src/renderer/status-token.ts +71 -0
  42. package/src/version.ts +1 -1
@@ -1,14 +1,13 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { PluginManagerObserver, PluginStatus } from '@pellux/goodvibes-sdk/platform/plugins/manager';
5
5
  import {
6
6
  buildEmptyState,
7
7
  buildPanelLine,
8
8
  buildPanelWorkspace,
9
9
  DEFAULT_PANEL_PALETTE,
10
- resolvePrimaryScrollableSection,
11
- type PanelWorkspaceSection,
10
+ type PanelPalette,
12
11
  } from './polish.ts';
13
12
 
14
13
  const C = {
@@ -47,11 +46,9 @@ function statusLabel(status: PluginStatus): string {
47
46
  return 'DISABLED';
48
47
  }
49
48
 
50
- export class PluginsPanel extends BasePanel {
49
+ export class PluginsPanel extends ScrollableListPanel<PluginStatus> {
51
50
  private readonly manager: PluginManagerObserver;
52
51
  private readonly unsub: (() => void) | null;
53
- private selectedIndex = 0;
54
- private scrollOffset = 0;
55
52
 
56
53
  public constructor(manager: PluginManagerObserver) {
57
54
  super('plugins', 'Plugins', 'P', 'monitoring');
@@ -68,26 +65,39 @@ export class PluginsPanel extends BasePanel {
68
65
  this.unsub?.();
69
66
  }
70
67
 
71
- public handleInput(key: string): boolean {
72
- const plugins = this.manager.list();
73
- if (plugins.length === 0) return false;
74
- if (key === 'up' || key === 'k') {
75
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
76
- this.markDirty();
77
- return true;
78
- }
79
- if (key === 'down' || key === 'j') {
80
- this.selectedIndex = Math.min(plugins.length - 1, this.selectedIndex + 1);
81
- this.markDirty();
82
- return true;
83
- }
84
- return false;
68
+ protected override getPalette(): PanelPalette {
69
+ return C;
70
+ }
71
+
72
+ protected getItems(): readonly PluginStatus[] {
73
+ return this.manager.list();
74
+ }
75
+
76
+ protected renderItem(plugin: PluginStatus, _index: number, selected: boolean, width: number): Line {
77
+ const bg = selected ? C.selectBg : undefined;
78
+ return buildPanelLine(width, [
79
+ [' ', C.label, bg],
80
+ [plugin.name.padEnd(22), C.value, bg],
81
+ [` ${statusLabel(plugin).padEnd(11)}`, statusColor(plugin), bg],
82
+ [` ${plugin.trustTier.toUpperCase().padEnd(10)}`, trustColor(plugin.trustTier), bg],
83
+ [` ${plugin.version}`, C.dim, bg],
84
+ ]);
85
+ }
86
+
87
+ protected override getEmptyStateMessage(): string {
88
+ return ' No plugins discovered.';
89
+ }
90
+
91
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
92
+ return [
93
+ { command: '/plugin list', summary: 'inspect plugin discovery paths and current registry state' },
94
+ { command: '/marketplace', summary: 'review curated ecosystem entries and provenance posture' },
95
+ ];
85
96
  }
86
97
 
87
98
  public render(width: number, height: number): Line[] {
88
- this.needsRender = false;
89
99
  const intro = 'Plugin trust, capabilities, signatures, and quarantine posture for the active ecosystem surface.';
90
- const plugins = this.manager.list();
100
+ const plugins = this.getItems();
91
101
 
92
102
  if (plugins.length === 0) {
93
103
  const workspace = buildPanelWorkspace(width, height, {
@@ -111,7 +121,7 @@ export class PluginsPanel extends BasePanel {
111
121
  return workspace;
112
122
  }
113
123
 
114
- this.selectedIndex = Math.min(this.selectedIndex, plugins.length - 1);
124
+ this.clampSelection();
115
125
  const selected = plugins[this.selectedIndex]!;
116
126
  const selectedCaps = this.manager.capabilities(selected.name);
117
127
  const trustRecord = this.manager.getTrustRecord(selected.name);
@@ -157,45 +167,11 @@ export class PluginsPanel extends BasePanel {
157
167
  }
158
168
 
159
169
  detailLines.push(buildPanelLine(width, [[' Inspect trust and capability state here, then use /plugin to take action.', C.dim]]));
160
- const detailSection: PanelWorkspaceSection = { title: 'Selected Plugin', lines: detailLines };
161
- const resolvedPluginsSection = resolvePrimaryScrollableSection(width, height, {
162
- intro,
163
- footerLines: [buildPanelLine(width, [[' Up/Down move through discovered plugins', C.dim]])],
164
- palette: C,
165
- section: {
166
- title: 'Plugins',
167
- scrollableLines: plugins.map((plugin, absolute) => {
168
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
169
- return buildPanelLine(width, [
170
- [' ', C.label, bg],
171
- [plugin.name.padEnd(22), C.value, bg],
172
- [` ${statusLabel(plugin).padEnd(11)}`, statusColor(plugin), bg],
173
- [` ${plugin.trustTier.toUpperCase().padEnd(10)}`, trustColor(plugin.trustTier), bg],
174
- [` ${plugin.version}`, C.dim, bg],
175
- ]);
176
- }),
177
- selectedIndex: this.selectedIndex,
178
- scrollOffset: this.scrollOffset,
179
- guardRows: 1,
180
- minRows: 4,
181
- appendWindowSummary: { dimColor: C.dim },
182
- },
183
- afterSections: [detailSection],
184
- });
185
- this.scrollOffset = resolvedPluginsSection.scrollOffset;
170
+ detailLines.push(buildPanelLine(width, [[' Up/Down move through discovered plugins', C.dim]]));
186
171
 
187
- const sections: PanelWorkspaceSection[] = [
188
- resolvedPluginsSection.section,
189
- detailSection,
190
- ];
191
- const lines = buildPanelWorkspace(width, height, {
172
+ return this.renderList(width, height, {
192
173
  title: 'Plugin Control Room',
193
- intro,
194
- sections,
195
- footerLines: [buildPanelLine(width, [[' Up/Down move through discovered plugins', C.dim]])],
196
- palette: C,
174
+ footer: detailLines,
197
175
  });
198
- while (lines.length < height) lines.push(createEmptyLine(width));
199
- return lines.slice(0, height);
200
176
  }
201
177
  }
@@ -1,6 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { UiReadModel, UiRoutesSnapshot } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
6
  import {
@@ -10,8 +10,7 @@ import {
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
12
  DEFAULT_PANEL_PALETTE,
13
- resolvePrimaryScrollableSection,
14
- type PanelWorkspaceSection,
13
+ type PanelPalette,
15
14
  } from './polish.ts';
16
15
 
17
16
  const C = {
@@ -30,11 +29,11 @@ function formatTime(value?: number): string {
30
29
  return new Date(value).toLocaleString();
31
30
  }
32
31
 
33
- export class RoutesPanel extends BasePanel {
32
+ type RouteBinding = UiRoutesSnapshot['bindings'][number];
33
+
34
+ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
34
35
  private readonly readModel?: UiReadModel<UiRoutesSnapshot>;
35
36
  private readonly unsub: (() => void) | null;
36
- private selectedIndex = 0;
37
- private scrollOffset = 0;
38
37
 
39
38
  public constructor(readModel?: UiReadModel<UiRoutesSnapshot>) {
40
39
  super('routes', 'Routes', 'R', 'monitoring');
@@ -46,29 +45,38 @@ export class RoutesPanel extends BasePanel {
46
45
  this.unsub?.();
47
46
  }
48
47
 
49
- public handleInput(key: string): boolean {
50
- const bindings = this.bindings();
51
- if (bindings.length === 0) return false;
52
- if (key === 'up' || key === 'k') {
53
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
54
- this.markDirty();
55
- return true;
56
- }
57
- if (key === 'down' || key === 'j') {
58
- this.selectedIndex = Math.min(bindings.length - 1, this.selectedIndex + 1);
59
- this.markDirty();
60
- return true;
61
- }
62
- return false;
48
+ protected override getPalette(): PanelPalette {
49
+ return C;
63
50
  }
64
51
 
65
- private bindings() {
52
+ protected getItems(): readonly RouteBinding[] {
66
53
  if (!this.readModel) return [];
67
- return [...this.readModel.getSnapshot().bindings];
54
+ return this.readModel.getSnapshot().bindings;
55
+ }
56
+
57
+ protected renderItem(binding: RouteBinding, _index: number, selected: boolean, width: number): Line {
58
+ const bg = selected ? C.selectBg : undefined;
59
+ return buildPanelLine(width, [
60
+ [' ', C.label, bg],
61
+ [binding.surfaceKind.padEnd(9), C.info, bg],
62
+ [` ${truncateDisplay(binding.title ?? binding.externalId, 22).padEnd(22)}`, C.value, bg],
63
+ [` ${truncateDisplay(binding.sessionId ?? binding.runId ?? 'unbound', 18).padEnd(18)}`, binding.sessionId ? C.ok : C.warn, bg],
64
+ [` ${truncateDisplay(formatTime(binding.lastSeenAt), Math.max(0, width - 54))}`, C.dim, bg],
65
+ ]);
66
+ }
67
+
68
+ protected override getEmptyStateMessage(): string {
69
+ return ' No route bindings recorded.';
70
+ }
71
+
72
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
73
+ return [
74
+ { command: '/schedule list', summary: 'run jobs and triggers that create route bindings' },
75
+ { command: '/communication', summary: 'inspect routed communication once a surface is active' },
76
+ ];
68
77
  }
69
78
 
70
79
  public render(width: number, height: number): Line[] {
71
- this.needsRender = false;
72
80
  const intro = 'External route bindings that preserve thread, session, and reply context across Slack, Discord, ntfy, webhook, web, and TUI surfaces.';
73
81
 
74
82
  if (!this.readModel) {
@@ -91,138 +99,78 @@ export class RoutesPanel extends BasePanel {
91
99
  }
92
100
 
93
101
  const snapshot = this.readModel.getSnapshot();
94
- const bindings = this.bindings();
102
+ const bindings = this.getItems();
95
103
  const surfaceEntries = Object.entries(snapshot.bindingIdsBySurface)
96
104
  .filter(([, ids]) => ids.length > 0)
97
105
  .sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
98
106
 
99
- const summarySection: PanelWorkspaceSection = {
100
- title: 'Posture',
101
- lines: [
102
- buildKeyValueLine(width, [
103
- { label: 'bindings', value: String(snapshot.totalBindings), valueColor: snapshot.totalBindings > 0 ? C.info : C.dim },
104
- { label: 'active', value: String(snapshot.activeBindingIds.length), valueColor: snapshot.activeBindingIds.length > 0 ? C.ok : C.dim },
105
- { label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.ok : C.dim },
106
- { label: 'failures', value: String(snapshot.totalFailures), valueColor: snapshot.totalFailures > 0 ? C.error : C.dim },
107
- ], C),
108
- buildGuidanceLine(width, '/communication', 'inspect routed message flow and delivery behavior across bound surfaces', C),
109
- ],
110
- };
107
+ const headerLines: Line[] = [
108
+ buildKeyValueLine(width, [
109
+ { label: 'bindings', value: String(snapshot.totalBindings), valueColor: snapshot.totalBindings > 0 ? C.info : C.dim },
110
+ { label: 'active', value: String(snapshot.activeBindingIds.length), valueColor: snapshot.activeBindingIds.length > 0 ? C.ok : C.dim },
111
+ { label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.ok : C.dim },
112
+ { label: 'failures', value: String(snapshot.totalFailures), valueColor: snapshot.totalFailures > 0 ? C.error : C.dim },
113
+ ], C),
114
+ buildGuidanceLine(width, '/communication', 'inspect routed message flow and delivery behavior across bound surfaces', C),
115
+ ];
111
116
 
112
117
  if (bindings.length === 0) {
113
- const workspace = buildPanelWorkspace(width, height, {
118
+ return this.renderList(width, height, {
114
119
  title: 'Route Bindings',
115
- intro,
116
- sections: [
117
- summarySection,
118
- {
119
- lines: buildEmptyState(
120
- width,
121
- ' No route bindings recorded.',
122
- 'Bindings appear when the daemon links an external surface, thread, or remote client to a shared session or automation run.',
123
- [
124
- { command: '/schedule list', summary: 'run jobs and triggers that create route bindings' },
125
- { command: '/communication', summary: 'inspect routed communication once a surface is active' },
126
- ],
127
- C,
128
- ),
129
- },
130
- ],
131
- palette: C,
120
+ header: headerLines,
121
+ emptyMessage: ' No route bindings recorded.',
132
122
  });
133
- while (workspace.length < height) workspace.push(createEmptyLine(width));
134
- return workspace;
135
123
  }
136
124
 
137
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, bindings.length - 1));
125
+ this.clampSelection();
138
126
  const selected = bindings[this.selectedIndex]!;
139
127
 
140
- const surfaceSection: PanelWorkspaceSection = {
141
- title: 'Surfaces',
142
- lines: surfaceEntries.length > 0
143
- ? surfaceEntries.slice(0, 6).map(([surface, ids]) => buildPanelLine(width, [
144
- [' ', C.label],
145
- [surface.padEnd(10), C.info],
146
- [` ${String(ids.length)} binding(s)`, C.value],
147
- ]))
148
- : [buildPanelLine(width, [[' No surface counts recorded.', C.dim]])],
149
- };
150
-
151
- const detailSection: PanelWorkspaceSection = {
152
- title: 'Selected Binding',
153
- lines: [
154
- buildPanelLine(width, [
155
- [' Binding: ', C.label],
156
- [selected.id, C.value],
157
- [' Surface: ', C.label],
158
- [selected.surfaceKind, C.info],
159
- ]),
160
- buildPanelLine(width, [
161
- [' External: ', C.label],
162
- [truncateDisplay(selected.externalId, 28), C.value],
163
- [' Kind: ', C.label],
164
- [selected.kind, C.dim],
165
- ]),
166
- buildPanelLine(width, [
167
- [' Session: ', C.label],
168
- [selected.sessionId ?? 'n/a', C.value],
169
- [' Run: ', C.label],
170
- [selected.runId ?? 'n/a', C.dim],
171
- ]),
172
- buildPanelLine(width, [
173
- [' Channel: ', C.label],
174
- [selected.channelId ?? 'n/a', C.dim],
175
- [' Thread: ', C.label],
176
- [selected.threadId ?? 'n/a', C.dim],
177
- ]),
178
- buildPanelLine(width, [
179
- [' Last seen: ', C.label],
180
- [formatTime(selected.lastSeenAt), C.dim],
181
- ]),
182
- ],
183
- };
184
-
185
- const resolvedBindings = resolvePrimaryScrollableSection(width, height, {
186
- intro,
187
- footerLines: [buildPanelLine(width, [[' Up/Down move through route bindings', C.dim]])],
188
- palette: C,
189
- beforeSections: [summarySection],
190
- section: {
191
- title: 'Bindings',
192
- scrollableLines: bindings.map((binding, absolute) => {
193
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
194
- return buildPanelLine(width, [
195
- [' ', C.label, bg],
196
- [binding.surfaceKind.padEnd(9), C.info, bg],
197
- [` ${truncateDisplay(binding.title ?? binding.externalId, 22).padEnd(22)}`, C.value, bg],
198
- [` ${truncateDisplay(binding.sessionId ?? binding.runId ?? 'unbound', 18).padEnd(18)}`, binding.sessionId ? C.ok : C.warn, bg],
199
- [` ${truncateDisplay(formatTime(binding.lastSeenAt), Math.max(0, width - 54))}`, C.dim, bg],
200
- ]);
201
- }),
202
- selectedIndex: this.selectedIndex,
203
- scrollOffset: this.scrollOffset,
204
- guardRows: 1,
205
- minRows: 5,
206
- appendWindowSummary: { dimColor: C.dim },
207
- },
208
- afterSections: [detailSection, surfaceSection],
209
- });
210
- this.scrollOffset = resolvedBindings.scrollOffset;
211
-
212
- const sections: PanelWorkspaceSection[] = [
213
- summarySection,
214
- resolvedBindings.section,
215
- detailSection,
216
- surfaceSection,
128
+ const footerLines: Line[] = [
129
+ buildPanelLine(width, [
130
+ [' Binding: ', C.label],
131
+ [selected.id, C.value],
132
+ [' Surface: ', C.label],
133
+ [selected.surfaceKind, C.info],
134
+ ]),
135
+ buildPanelLine(width, [
136
+ [' External: ', C.label],
137
+ [truncateDisplay(selected.externalId, 28), C.value],
138
+ [' Kind: ', C.label],
139
+ [selected.kind, C.dim],
140
+ ]),
141
+ buildPanelLine(width, [
142
+ [' Session: ', C.label],
143
+ [selected.sessionId ?? 'n/a', C.value],
144
+ [' Run: ', C.label],
145
+ [selected.runId ?? 'n/a', C.dim],
146
+ ]),
147
+ buildPanelLine(width, [
148
+ [' Channel: ', C.label],
149
+ [selected.channelId ?? 'n/a', C.dim],
150
+ [' Thread: ', C.label],
151
+ [selected.threadId ?? 'n/a', C.dim],
152
+ ]),
153
+ buildPanelLine(width, [
154
+ [' Last seen: ', C.label],
155
+ [formatTime(selected.lastSeenAt), C.dim],
156
+ ]),
217
157
  ];
218
- const lines = buildPanelWorkspace(width, height, {
158
+
159
+ if (surfaceEntries.length > 0) {
160
+ footerLines.push(
161
+ ...surfaceEntries.slice(0, 6).map(([surface, ids]) => buildPanelLine(width, [
162
+ [' ', C.label],
163
+ [surface.padEnd(10), C.info],
164
+ [` ${String(ids.length)} binding(s)`, C.value],
165
+ ])),
166
+ );
167
+ }
168
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through route bindings', C.dim]]));
169
+
170
+ return this.renderList(width, height, {
219
171
  title: 'Route Bindings',
220
- intro,
221
- sections,
222
- footerLines: [buildPanelLine(width, [[' Up/Down move through route bindings', C.dim]])],
223
- palette: C,
172
+ header: headerLines,
173
+ footer: footerLines,
224
174
  });
225
- while (lines.length < height) lines.push(createEmptyLine(width));
226
- return lines.slice(0, height);
227
175
  }
228
176
  }
@@ -1,15 +1,17 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
2
+ import { createEmptyLine, createStyledCell } from '../types/grid.ts';
3
3
  import { BasePanel } from './base-panel.ts';
4
4
  import type { PanelCategory } from './types.ts';
5
5
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
6
6
  import {
7
7
  buildEmptyState,
8
8
  buildPanelWorkspace,
9
+ buildSearchInputLine,
9
10
  DEFAULT_PANEL_PALETTE,
10
11
  resolveScrollablePanelSection,
11
12
  type PanelPalette,
12
13
  } from './polish.ts';
14
+ import { GLYPHS } from '../renderer/ui-primitives.ts';
13
15
  import {
14
16
  isPanelSearchBackspace,
15
17
  isPanelSearchCancel,
@@ -47,6 +49,12 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
47
49
  protected selectedIndex = 0;
48
50
  /** Tracks the first visible row index; kept in sync with resolveScrollablePanelSection. */
49
51
  protected scrollStart = 0;
52
+ /**
53
+ * When true, prepends a 2-column `▸ ` gutter on the selected row.
54
+ * Unselected rows get ` ` (two spaces) to maintain alignment.
55
+ * Opt-in; default false to avoid breaking existing panel layouts.
56
+ */
57
+ protected showSelectionGutter = false;
50
58
 
51
59
  constructor(
52
60
  id: string,
@@ -115,6 +123,9 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
115
123
  // -------------------------------------------------------------------------
116
124
 
117
125
  handleInput(key: string): boolean {
126
+ // I2: auto-clear error on next keypress
127
+ if (this.lastError) this.clearError();
128
+
118
129
  const items = this.getItems();
119
130
  const total = items.length;
120
131
 
@@ -205,6 +216,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
205
216
  * @param options.footer Lines appended as the last workspace section.
206
217
  * @param options.emptyMessage Override for the empty-state title text.
207
218
  * @param options.title Workspace title (defaults to `this.name`).
219
+ * @param options.spinnerFrame Animation frame for the loading spinner.
208
220
  */
209
221
  protected renderList(
210
222
  width: number,
@@ -214,6 +226,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
214
226
  readonly footer?: readonly Line[];
215
227
  readonly emptyMessage?: string;
216
228
  readonly title?: string;
229
+ readonly spinnerFrame?: number;
217
230
  } = {},
218
231
  ): Line[] {
219
232
  this.needsRender = false;
@@ -221,11 +234,47 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
221
234
  const items = this.getItems();
222
235
  const title = options.title ?? this.name;
223
236
 
237
+ // I2: inject error line into footer when present
238
+ const errorLine = this.renderErrorLine(width);
239
+ const baseFooter = options.footer ? [...options.footer as Line[]] : [];
240
+ const effectiveFooter: Line[] = errorLine ? [errorLine, ...baseFooter] : baseFooter;
241
+
242
+ // I3: if loading, show spinner in place of normal content
243
+ const spinnerLine = this.renderLoadingLine(width, options.spinnerFrame ?? 0);
244
+ if (spinnerLine) {
245
+ const loadingSection = { lines: [spinnerLine] };
246
+ const headerSection = options.header ? [{ lines: options.header as Line[] }] : [];
247
+ const lines = buildPanelWorkspace(width, height, {
248
+ title,
249
+ sections: [...headerSection, loadingSection],
250
+ palette,
251
+ });
252
+ while (lines.length < height) lines.push(createEmptyLine(width));
253
+ return lines.slice(0, height);
254
+ }
255
+
224
256
  // Build all item lines (pre-render for resolveScrollablePanelSection)
225
257
  const scrollableLines: Line[] = items.map((item, index) =>
226
258
  this.renderItem(item, index, index === this.selectedIndex, width),
227
259
  );
228
260
 
261
+ // I5: prepend selection gutter when opted in
262
+ if (this.showSelectionGutter) {
263
+ const infoColor = this.getPalette().info ?? DEFAULT_PANEL_PALETTE.info;
264
+ const dimColor = this.getPalette().dim;
265
+ for (let i = 0; i < scrollableLines.length; i++) {
266
+ const line = scrollableLines[i]!;
267
+ const isSelected = i === this.selectedIndex;
268
+ // Shift all cells right by 2, drop the last 2 to preserve width
269
+ const shifted = line.slice(0, width - 2);
270
+ const gutterChar = isSelected ? GLYPHS.navigation.selected : ' ';
271
+ const gutterFg = isSelected ? infoColor : dimColor;
272
+ const g0 = createStyledCell(gutterChar, { fg: gutterFg, bold: isSelected });
273
+ const g1 = createStyledCell(' ', { fg: gutterFg });
274
+ scrollableLines[i] = [g0, g1, ...shifted] as Line;
275
+ }
276
+ }
277
+
229
278
  // Empty state
230
279
  if (scrollableLines.length === 0) {
231
280
  const emptyLines = buildEmptyState(
@@ -240,7 +289,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
240
289
  sections: [
241
290
  ...(options.header ? [{ lines: options.header as Line[] }] : []),
242
291
  { lines: emptyLines },
243
- ...(options.footer ? [{ lines: options.footer as Line[] }] : []),
292
+ ...(effectiveFooter.length > 0 ? [{ lines: effectiveFooter }] : []),
244
293
  ],
245
294
  palette,
246
295
  });
@@ -250,7 +299,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
250
299
 
251
300
  // Resolve scrollable section (updates scrollStart)
252
301
  const beforeSections = options.header ? [{ lines: options.header as Line[] }] : [];
253
- const afterSections = options.footer ? [{ lines: options.footer as Line[] }] : [];
302
+ const afterSections = effectiveFooter.length > 0 ? [{ lines: effectiveFooter }] : [];
254
303
 
255
304
  const resolved = resolveScrollablePanelSection(width, height, {
256
305
  palette,
@@ -387,21 +436,27 @@ export abstract class SearchableListPanel<T> extends ScrollableListPanel<T> {
387
436
  }
388
437
 
389
438
  /**
390
- * Build the search input `Line` suitable for use in a panel header.
439
+ * Build the filter input `Line` for use in a panel header section.
391
440
  *
392
- * Import `buildSearchInputLine` from `./polish.ts` and call it with
393
- * `this.searchQuery`. Convenience wrapper:
441
+ * Renders the filter label and current query with context-sensitive formatting:
394
442
  *
395
- * ```ts
396
- * import { buildSearchInputLine } from './polish.ts';
397
- *
398
- * private buildHeader(width: number): Line[] {
399
- * return [buildSearchInputLine(width, 'Filter', this.searchQuery, this.getPalette(), {})];
400
- * }
401
- * ```
443
+ * - **Focused** (`focused = true`): `[Filter] query_` — active, bold, cursor visible
444
+ * - **Unfocused** (`focused = false`): `Filter: query` — dim, no cursor
402
445
  *
403
- * This method is intentionally left as a documentation reference rather
404
- * than a concrete implementation to avoid coupling the base class to a
405
- * specific label or search-input layout.
446
+ * @param width Panel width in columns.
447
+ * @param label Label text (default: `'Filter'`).
448
+ * @param focused Whether the filter input is currently active.
406
449
  */
450
+ protected buildFilterInputLine(width: number, label = 'Filter', focused: boolean): Line {
451
+ const palette = this.getPalette();
452
+ const formattedLabel = focused ? `[${label}] ` : `${label}: `;
453
+ const value = focused ? `${this.searchQuery}_` : this.searchQuery;
454
+ // Pass active:false when focused to prevent buildSearchInputLine from converting the
455
+ // trailing '_' cursor to the block-glyph (GLYPHS.surface.cursor). The focused visual
456
+ // affordance is provided by the '[Label] ' bracket format and explicit inputBg/info colors.
457
+ const opts = focused
458
+ ? { active: false, bg: palette.inputBg, valueColor: palette.info }
459
+ : { active: false };
460
+ return buildSearchInputLine(width, formattedLabel, value, palette, opts);
461
+ }
407
462
  }