@pellux/goodvibes-tui 0.18.20 → 0.19.0

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 (83) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +22 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/session.ts +0 -1
  13. package/src/input/commands/shell-core.ts +3 -2
  14. package/src/input/commands/skills-runtime.ts +2 -2
  15. package/src/input/commands/subscription-runtime.ts +4 -4
  16. package/src/input/feed-context-factory.ts +236 -0
  17. package/src/input/handler-feed.ts +44 -6
  18. package/src/input/handler-shortcuts.ts +138 -125
  19. package/src/input/handler.ts +119 -119
  20. package/src/input/keybindings.ts +30 -0
  21. package/src/input/panel-integration-actions.ts +2 -1
  22. package/src/input/settings-modal-types.ts +60 -0
  23. package/src/input/settings-modal.ts +83 -65
  24. package/src/panels/agent-inspector-panel.ts +10 -9
  25. package/src/panels/agent-logs-panel.ts +26 -6
  26. package/src/panels/approval-panel.ts +55 -82
  27. package/src/panels/automation-control-panel.ts +120 -161
  28. package/src/panels/base-panel.ts +108 -3
  29. package/src/panels/communication-panel.ts +69 -107
  30. package/src/panels/context-visualizer-panel.ts +2 -0
  31. package/src/panels/control-plane-panel.ts +117 -172
  32. package/src/panels/diff-panel.ts +2 -0
  33. package/src/panels/file-explorer-panel.ts +51 -31
  34. package/src/panels/file-preview-panel.ts +57 -35
  35. package/src/panels/git-panel.ts +12 -13
  36. package/src/panels/hooks-panel.ts +103 -138
  37. package/src/panels/incident-review-panel.ts +59 -109
  38. package/src/panels/knowledge-panel.ts +75 -107
  39. package/src/panels/local-auth-panel.ts +77 -93
  40. package/src/panels/marketplace-panel.ts +51 -69
  41. package/src/panels/mcp-panel.ts +110 -155
  42. package/src/panels/memory-panel.ts +90 -158
  43. package/src/panels/ops-control-panel.ts +51 -85
  44. package/src/panels/orchestration-panel.ts +70 -51
  45. package/src/panels/panel-list-panel.ts +5 -4
  46. package/src/panels/panel-manager.ts +25 -2
  47. package/src/panels/plan-dashboard-panel.ts +2 -0
  48. package/src/panels/plugins-panel.ts +37 -60
  49. package/src/panels/polish.ts +51 -2
  50. package/src/panels/provider-accounts-panel.ts +1 -0
  51. package/src/panels/provider-health-panel.ts +6 -8
  52. package/src/panels/routes-panel.ts +91 -141
  53. package/src/panels/schedule-panel.ts +7 -6
  54. package/src/panels/scrollable-list-panel.ts +64 -16
  55. package/src/panels/security-panel.ts +118 -152
  56. package/src/panels/services-panel.ts +63 -105
  57. package/src/panels/session-browser-panel.ts +19 -18
  58. package/src/panels/settings-sync-panel.ts +79 -123
  59. package/src/panels/skills-panel.ts +114 -230
  60. package/src/panels/subscription-panel.ts +64 -86
  61. package/src/panels/system-messages-panel.ts +147 -141
  62. package/src/panels/tasks-panel.ts +130 -179
  63. package/src/panels/token-budget-panel.ts +2 -0
  64. package/src/panels/watchers-panel.ts +89 -137
  65. package/src/panels/worktree-panel.ts +1 -0
  66. package/src/panels/wrfc-panel.ts +2 -0
  67. package/src/renderer/agent-detail-modal.ts +2 -2
  68. package/src/renderer/ansi-sanitize.ts +76 -0
  69. package/src/renderer/buffer.ts +23 -1
  70. package/src/renderer/diff.ts +8 -0
  71. package/src/renderer/help-overlay.ts +48 -28
  72. package/src/renderer/markdown.ts +3 -145
  73. package/src/renderer/settings-modal-helpers.ts +27 -0
  74. package/src/renderer/settings-modal.ts +18 -1
  75. package/src/renderer/status-glyphs.ts +21 -0
  76. package/src/renderer/status-token.ts +4 -8
  77. package/src/renderer/tool-call.ts +4 -3
  78. package/src/runtime/bootstrap-core.ts +1 -1
  79. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  80. package/src/runtime/bootstrap.ts +7 -8
  81. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  82. package/src/shell/ui-openers.ts +1 -1
  83. package/src/version.ts +1 -1
@@ -362,7 +362,7 @@ function buildProviderRuntimeRecord(
362
362
  */
363
363
  export class ProviderHealthPanel extends BasePanel {
364
364
  private _unsubs: Array<() => void> = [];
365
- private _refreshTimer: ReturnType<typeof setInterval> | null = null;
365
+ private _refreshTimerId: ReturnType<typeof setInterval> | null = null;
366
366
  private _selectedIndex = 0;
367
367
  private _scrollOffset = 0;
368
368
  private _accountRecords = new Map<string, ProviderRuntimeRecord>();
@@ -461,23 +461,21 @@ export class ProviderHealthPanel extends BasePanel {
461
461
  }
462
462
 
463
463
  override onDestroy(): void {
464
- if (this._refreshTimer !== null) {
465
- clearInterval(this._refreshTimer);
466
- this._refreshTimer = null;
467
- }
464
+ super.onDestroy();
465
+ this._refreshTimerId = null;
468
466
  for (const unsub of this._unsubs) unsub();
469
467
  this._unsubs = [];
470
468
  }
471
469
 
472
470
  private _ensureRefreshTimer(): void {
473
- if (this._refreshTimer !== null) return;
474
- this._refreshTimer = setInterval(() => {
471
+ if (this._refreshTimerId !== null) return;
472
+ this._refreshTimerId = this.registerTimer(setInterval(() => {
475
473
  if (Date.now() - this._accountRefreshAt > 30_000) {
476
474
  void this._refreshAccountPosture();
477
475
  }
478
476
  this.markDirty();
479
477
  this.requestRender();
480
- }, 1_000);
478
+ }, 1_000));
481
479
  }
482
480
 
483
481
  handleInput(key: string): boolean {
@@ -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 {
@@ -9,9 +9,9 @@ import {
9
9
  buildKeyValueLine,
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
+ buildStatusPill,
12
13
  DEFAULT_PANEL_PALETTE,
13
- resolvePrimaryScrollableSection,
14
- type PanelWorkspaceSection,
14
+ type PanelPalette,
15
15
  } from './polish.ts';
16
16
 
17
17
  const C = {
@@ -30,14 +30,15 @@ function formatTime(value?: number): string {
30
30
  return new Date(value).toLocaleString();
31
31
  }
32
32
 
33
- export class RoutesPanel extends BasePanel {
33
+ type RouteBinding = UiRoutesSnapshot['bindings'][number];
34
+
35
+ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
34
36
  private readonly readModel?: UiReadModel<UiRoutesSnapshot>;
35
37
  private readonly unsub: (() => void) | null;
36
- private selectedIndex = 0;
37
- private scrollOffset = 0;
38
38
 
39
39
  public constructor(readModel?: UiReadModel<UiRoutesSnapshot>) {
40
40
  super('routes', 'Routes', 'R', 'monitoring');
41
+ this.showSelectionGutter = true; // I5: non-color selection affordance
41
42
  this.readModel = readModel;
42
43
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
43
44
  }
@@ -46,29 +47,38 @@ export class RoutesPanel extends BasePanel {
46
47
  this.unsub?.();
47
48
  }
48
49
 
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;
50
+ protected override getPalette(): PanelPalette {
51
+ return C;
63
52
  }
64
53
 
65
- private bindings() {
54
+ protected getItems(): readonly RouteBinding[] {
66
55
  if (!this.readModel) return [];
67
- return [...this.readModel.getSnapshot().bindings];
56
+ return this.readModel.getSnapshot().bindings;
57
+ }
58
+
59
+ protected renderItem(binding: RouteBinding, _index: number, selected: boolean, width: number): Line {
60
+ const bg = selected ? C.selectBg : undefined;
61
+ return buildPanelLine(width, [
62
+ [' ', C.label, bg],
63
+ [binding.surfaceKind.padEnd(9), C.info, bg],
64
+ [` ${truncateDisplay(binding.title ?? binding.externalId, 22).padEnd(22)}`, C.value, bg],
65
+ ...buildStatusPill(binding.sessionId ? 'good' : 'warn', ` ${truncateDisplay(binding.sessionId ?? binding.runId ?? 'unbound', 18).padEnd(18)}`, { bg }),
66
+ [` ${truncateDisplay(formatTime(binding.lastSeenAt), Math.max(0, width - 54))}`, C.dim, bg],
67
+ ]);
68
+ }
69
+
70
+ protected override getEmptyStateMessage(): string {
71
+ return ' No route bindings recorded.';
72
+ }
73
+
74
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
75
+ return [
76
+ { command: '/schedule list', summary: 'run jobs and triggers that create route bindings' },
77
+ { command: '/communication', summary: 'inspect routed communication once a surface is active' },
78
+ ];
68
79
  }
69
80
 
70
81
  public render(width: number, height: number): Line[] {
71
- this.needsRender = false;
72
82
  const intro = 'External route bindings that preserve thread, session, and reply context across Slack, Discord, ntfy, webhook, web, and TUI surfaces.';
73
83
 
74
84
  if (!this.readModel) {
@@ -91,138 +101,78 @@ export class RoutesPanel extends BasePanel {
91
101
  }
92
102
 
93
103
  const snapshot = this.readModel.getSnapshot();
94
- const bindings = this.bindings();
104
+ const bindings = this.getItems();
95
105
  const surfaceEntries = Object.entries(snapshot.bindingIdsBySurface)
96
106
  .filter(([, ids]) => ids.length > 0)
97
107
  .sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
98
108
 
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
- };
109
+ const headerLines: Line[] = [
110
+ buildKeyValueLine(width, [
111
+ { label: 'bindings', value: String(snapshot.totalBindings), valueColor: snapshot.totalBindings > 0 ? C.info : C.dim },
112
+ { label: 'active', value: String(snapshot.activeBindingIds.length), valueColor: snapshot.activeBindingIds.length > 0 ? C.ok : C.dim },
113
+ { label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.ok : C.dim },
114
+ { label: 'failures', value: String(snapshot.totalFailures), valueColor: snapshot.totalFailures > 0 ? C.error : C.dim },
115
+ ], C),
116
+ buildGuidanceLine(width, '/communication', 'inspect routed message flow and delivery behavior across bound surfaces', C),
117
+ ];
111
118
 
112
119
  if (bindings.length === 0) {
113
- const workspace = buildPanelWorkspace(width, height, {
120
+ return this.renderList(width, height, {
114
121
  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,
122
+ header: headerLines,
123
+ emptyMessage: ' No route bindings recorded.',
132
124
  });
133
- while (workspace.length < height) workspace.push(createEmptyLine(width));
134
- return workspace;
135
125
  }
136
126
 
137
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, bindings.length - 1));
127
+ this.clampSelection();
138
128
  const selected = bindings[this.selectedIndex]!;
139
129
 
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,
130
+ const footerLines: Line[] = [
131
+ buildPanelLine(width, [
132
+ [' Binding: ', C.label],
133
+ [selected.id, C.value],
134
+ [' Surface: ', C.label],
135
+ [selected.surfaceKind, C.info],
136
+ ]),
137
+ buildPanelLine(width, [
138
+ [' External: ', C.label],
139
+ [truncateDisplay(selected.externalId, 28), C.value],
140
+ [' Kind: ', C.label],
141
+ [selected.kind, C.dim],
142
+ ]),
143
+ buildPanelLine(width, [
144
+ [' Session: ', C.label],
145
+ [selected.sessionId ?? 'n/a', C.value],
146
+ [' Run: ', C.label],
147
+ [selected.runId ?? 'n/a', C.dim],
148
+ ]),
149
+ buildPanelLine(width, [
150
+ [' Channel: ', C.label],
151
+ [selected.channelId ?? 'n/a', C.dim],
152
+ [' Thread: ', C.label],
153
+ [selected.threadId ?? 'n/a', C.dim],
154
+ ]),
155
+ buildPanelLine(width, [
156
+ [' Last seen: ', C.label],
157
+ [formatTime(selected.lastSeenAt), C.dim],
158
+ ]),
217
159
  ];
218
- const lines = buildPanelWorkspace(width, height, {
160
+
161
+ if (surfaceEntries.length > 0) {
162
+ footerLines.push(
163
+ ...surfaceEntries.slice(0, 6).map(([surface, ids]) => buildPanelLine(width, [
164
+ [' ', C.label],
165
+ [surface.padEnd(10), C.info],
166
+ [` ${String(ids.length)} binding(s)`, C.value],
167
+ ])),
168
+ );
169
+ }
170
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through route bindings', C.dim]]));
171
+
172
+ return this.renderList(width, height, {
219
173
  title: 'Route Bindings',
220
- intro,
221
- sections,
222
- footerLines: [buildPanelLine(width, [[' Up/Down move through route bindings', C.dim]])],
223
- palette: C,
174
+ header: headerLines,
175
+ footer: footerLines,
224
176
  });
225
- while (lines.length < height) lines.push(createEmptyLine(width));
226
- return lines.slice(0, height);
227
177
  }
228
178
  }
@@ -87,7 +87,7 @@ export class SchedulePanel extends BasePanel {
87
87
  private items: ViewItem[] = [];
88
88
  private selectedIndex = 0;
89
89
  private scrollOffset = 0;
90
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
90
+ private refreshTimerId: ReturnType<typeof setInterval> | null = null;
91
91
  private readonly automationManager: ScheduleAutomationManager;
92
92
 
93
93
  constructor(automationManager: ScheduleAutomationManager) {
@@ -106,21 +106,22 @@ export class SchedulePanel extends BasePanel {
106
106
  this.markDirty();
107
107
  });
108
108
  this.rebuild();
109
- this.refreshTimer = setInterval(() => {
109
+ this.refreshTimerId = this.registerTimer(setInterval(() => {
110
110
  this.rebuild();
111
111
  this.markDirty();
112
- }, 5_000);
112
+ }, 5_000));
113
113
  }
114
114
 
115
115
  override onDeactivate(): void {
116
- if (this.refreshTimer !== null) {
117
- clearInterval(this.refreshTimer);
118
- this.refreshTimer = null;
116
+ if (this.refreshTimerId !== null) {
117
+ this.clearTimer(this.refreshTimerId);
118
+ this.refreshTimerId = null;
119
119
  }
120
120
  }
121
121
 
122
122
  override onDestroy(): void {
123
123
  this.onDeactivate();
124
+ super.onDestroy();
124
125
  }
125
126
 
126
127
  // -------------------------------------------------------------------------
@@ -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,
@@ -114,9 +122,26 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
114
122
  // Navigation — consistent across ALL panels
115
123
  // -------------------------------------------------------------------------
116
124
 
125
+ /**
126
+ * Handle keyboard input for list navigation.
127
+ *
128
+ * **Auto-clearError contract**: At the top of this method, `lastError` is cleared if
129
+ * non-null. This means any transient error set via `setError()` is dismissed on the
130
+ * very next keystroke the user presses. Subclasses that override `handleInput()` should
131
+ * either:
132
+ * 1. Call `super.handleInput(key)` as a fallback (preferred), which will clear the
133
+ * error when navigation keys are pressed, or
134
+ * 2. Manually call `this.clearError()` at the top of their override to maintain
135
+ * the same contract for their handled keys.
136
+ *
137
+ * Returns `true` if the key was consumed, `false` to let the panel manager try another
138
+ * handler.
139
+ */
117
140
  handleInput(key: string): boolean {
118
- // I2: auto-clear error on next keypress
119
- if (this.lastError) this.clearError();
141
+ // I2: auto-clear transient errors on the next keystroke so stale errors don't linger.
142
+ // Subclasses that override handleInput should call super.handleInput(key) OR manually
143
+ // call this.clearError() at the start of their handler.
144
+ if (this.lastError !== null) this.clearError();
120
145
 
121
146
  const items = this.getItems();
122
147
  const total = items.length;
@@ -250,6 +275,23 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
250
275
  this.renderItem(item, index, index === this.selectedIndex, width),
251
276
  );
252
277
 
278
+ // I5: prepend selection gutter when opted in
279
+ if (this.showSelectionGutter) {
280
+ const infoColor = this.getPalette().info ?? DEFAULT_PANEL_PALETTE.info;
281
+ const dimColor = this.getPalette().dim;
282
+ for (let i = 0; i < scrollableLines.length; i++) {
283
+ const line = scrollableLines[i]!;
284
+ const isSelected = i === this.selectedIndex;
285
+ // Shift all cells right by 2, drop the last 2 to preserve width
286
+ const shifted = line.slice(0, width - 2);
287
+ const gutterChar = isSelected ? GLYPHS.navigation.selected : ' ';
288
+ const gutterFg = isSelected ? infoColor : dimColor;
289
+ const g0 = createStyledCell(gutterChar, { fg: gutterFg, bold: isSelected });
290
+ const g1 = createStyledCell(' ', { fg: gutterFg });
291
+ scrollableLines[i] = [g0, g1, ...shifted] as Line;
292
+ }
293
+ }
294
+
253
295
  // Empty state
254
296
  if (scrollableLines.length === 0) {
255
297
  const emptyLines = buildEmptyState(
@@ -411,21 +453,27 @@ export abstract class SearchableListPanel<T> extends ScrollableListPanel<T> {
411
453
  }
412
454
 
413
455
  /**
414
- * Build the search input `Line` suitable for use in a panel header.
456
+ * Build the filter input `Line` for use in a panel header section.
415
457
  *
416
- * Import `buildSearchInputLine` from `./polish.ts` and call it with
417
- * `this.searchQuery`. Convenience wrapper:
458
+ * Renders the filter label and current query with context-sensitive formatting:
418
459
  *
419
- * ```ts
420
- * import { buildSearchInputLine } from './polish.ts';
460
+ * - **Focused** (`focused = true`): `[Filter] query_` — active, bold, cursor visible
461
+ * - **Unfocused** (`focused = false`): `Filter: query` — dim, no cursor
421
462
  *
422
- * private buildHeader(width: number): Line[] {
423
- * return [buildSearchInputLine(width, 'Filter', this.searchQuery, this.getPalette(), {})];
424
- * }
425
- * ```
426
- *
427
- * This method is intentionally left as a documentation reference rather
428
- * than a concrete implementation to avoid coupling the base class to a
429
- * specific label or search-input layout.
463
+ * @param width Panel width in columns.
464
+ * @param label Label text (default: `'Filter'`).
465
+ * @param focused Whether the filter input is currently active.
430
466
  */
467
+ protected buildFilterInputLine(width: number, label = 'Filter', focused: boolean): Line {
468
+ const palette = this.getPalette();
469
+ const formattedLabel = focused ? `[${label}] ` : `${label}: `;
470
+ const value = focused ? `${this.searchQuery}_` : this.searchQuery;
471
+ // Pass active:false when focused to prevent buildSearchInputLine from converting the
472
+ // trailing '_' cursor to the block-glyph (GLYPHS.surface.cursor). The focused visual
473
+ // affordance is provided by the '[Label] ' bracket format and explicit inputBg/info colors.
474
+ const opts = focused
475
+ ? { active: false, bg: palette.inputBg, valueColor: palette.info }
476
+ : { active: false };
477
+ return buildSearchInputLine(width, formattedLabel, value, palette, opts);
478
+ }
431
479
  }