@pellux/goodvibes-tui 0.27.0 → 0.28.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 (101) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/eval.ts +4 -3
  9. package/src/input/handler-feed-routes.ts +61 -4
  10. package/src/input/handler-feed.ts +13 -0
  11. package/src/input/handler-picker-routes.ts +18 -13
  12. package/src/input/handler-ui-state.ts +4 -0
  13. package/src/input/keybindings.ts +5 -0
  14. package/src/input/model-picker.ts +5 -0
  15. package/src/input/panel-integration-actions.ts +10 -0
  16. package/src/main.ts +0 -1
  17. package/src/panels/agent-inspector-panel.ts +18 -13
  18. package/src/panels/agent-inspector-shared.ts +3 -3
  19. package/src/panels/agent-logs-panel.ts +22 -53
  20. package/src/panels/approval-panel.ts +146 -95
  21. package/src/panels/automation-control-panel.ts +24 -1
  22. package/src/panels/base-panel.ts +26 -2
  23. package/src/panels/builtin/development.ts +1 -1
  24. package/src/panels/cockpit-panel.ts +24 -5
  25. package/src/panels/cockpit-read-model.ts +2 -1
  26. package/src/panels/communication-panel.ts +108 -43
  27. package/src/panels/context-visualizer-panel.ts +25 -1
  28. package/src/panels/control-plane-panel.ts +27 -1
  29. package/src/panels/cost-tracker-panel.ts +82 -54
  30. package/src/panels/debug-panel.ts +51 -16
  31. package/src/panels/diff-panel.ts +59 -30
  32. package/src/panels/docs-panel.ts +19 -3
  33. package/src/panels/eval-panel.ts +130 -81
  34. package/src/panels/expandable-list-panel.ts +189 -0
  35. package/src/panels/file-explorer-panel.ts +40 -41
  36. package/src/panels/file-preview-panel.ts +9 -2
  37. package/src/panels/forensics-panel.ts +27 -13
  38. package/src/panels/git-panel.ts +58 -70
  39. package/src/panels/hooks-panel.ts +36 -2
  40. package/src/panels/incident-review-panel.ts +31 -10
  41. package/src/panels/intelligence-panel.ts +152 -71
  42. package/src/panels/knowledge-graph-panel.ts +89 -27
  43. package/src/panels/local-auth-panel.ts +17 -11
  44. package/src/panels/marketplace-panel.ts +31 -12
  45. package/src/panels/memory-panel.ts +5 -4
  46. package/src/panels/ops-control-panel.ts +69 -7
  47. package/src/panels/ops-strategy-panel.ts +61 -43
  48. package/src/panels/orchestration-panel.ts +34 -4
  49. package/src/panels/panel-list-panel.ts +33 -8
  50. package/src/panels/panel-manager.ts +23 -4
  51. package/src/panels/plan-dashboard-panel.ts +183 -66
  52. package/src/panels/plugins-panel.ts +48 -10
  53. package/src/panels/policy-panel.ts +60 -23
  54. package/src/panels/polish-core.ts +157 -0
  55. package/src/panels/polish-tables.ts +258 -0
  56. package/src/panels/polish.ts +44 -145
  57. package/src/panels/project-planning-panel.ts +22 -0
  58. package/src/panels/provider-accounts-panel.ts +55 -18
  59. package/src/panels/provider-health-panel.ts +65 -23
  60. package/src/panels/provider-stats-panel.ts +47 -22
  61. package/src/panels/qr-panel.ts +23 -11
  62. package/src/panels/remote-panel.ts +12 -5
  63. package/src/panels/routes-panel.ts +27 -5
  64. package/src/panels/sandbox-panel.ts +62 -27
  65. package/src/panels/schedule-panel.ts +40 -17
  66. package/src/panels/scrollable-list-panel.ts +124 -10
  67. package/src/panels/security-panel.ts +72 -20
  68. package/src/panels/services-panel.ts +68 -22
  69. package/src/panels/session-browser-panel.ts +29 -5
  70. package/src/panels/session-maintenance.ts +2 -2
  71. package/src/panels/settings-sync-panel.ts +30 -15
  72. package/src/panels/subscription-panel.ts +21 -3
  73. package/src/panels/symbol-outline-panel.ts +40 -47
  74. package/src/panels/system-messages-panel.ts +60 -27
  75. package/src/panels/tasks-panel.ts +34 -7
  76. package/src/panels/thinking-panel.ts +26 -25
  77. package/src/panels/token-budget-panel.ts +49 -38
  78. package/src/panels/tool-inspector-panel.ts +27 -21
  79. package/src/panels/types.ts +25 -0
  80. package/src/panels/watchers-panel.ts +25 -5
  81. package/src/panels/work-plan-panel.ts +125 -34
  82. package/src/panels/worktree-panel.ts +65 -20
  83. package/src/panels/wrfc-panel-format.ts +133 -0
  84. package/src/panels/wrfc-panel.ts +31 -127
  85. package/src/renderer/compaction-preview.ts +2 -1
  86. package/src/renderer/compositor.ts +46 -43
  87. package/src/renderer/model-picker-overlay.ts +9 -4
  88. package/src/renderer/model-workspace.ts +2 -3
  89. package/src/renderer/panel-composite.ts +7 -20
  90. package/src/renderer/panel-workspace-bar.ts +14 -8
  91. package/src/renderer/progress.ts +3 -2
  92. package/src/renderer/tab-strip.ts +148 -34
  93. package/src/renderer/ui-factory.ts +4 -6
  94. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  95. package/src/runtime/ui/model-picker/types.ts +4 -0
  96. package/src/utils/format-duration.ts +55 -0
  97. package/src/utils/format-number.ts +71 -0
  98. package/src/version.ts +1 -1
  99. package/src/panels/panel-picker.ts +0 -106
  100. package/src/renderer/panel-picker-overlay.ts +0 -202
  101. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -5,6 +5,7 @@ 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
+ buildKeyboardHints,
8
9
  buildPanelWorkspace,
9
10
  buildSearchInputLine,
10
11
  DEFAULT_PANEL_PALETTE,
@@ -56,6 +57,96 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
56
57
  */
57
58
  protected showSelectionGutter = false;
58
59
 
60
+ // -------------------------------------------------------------------------
61
+ // Opt-in inline filter ( / to filter, Esc to clear )
62
+ // -------------------------------------------------------------------------
63
+ // Set `filterEnabled = true` and override `filterMatches()` to give a list
64
+ // panel a `/`-to-filter affordance that coexists with single-letter action
65
+ // keys (filtering is modal: action keys work until you press `/`). A filter
66
+ // input line is auto-rendered at the top of the list.
67
+ protected filterEnabled = false;
68
+ protected filterQuery = '';
69
+ protected filterActive = false;
70
+ /** Short noun shown in the filter hint, e.g. "Filter sessions". */
71
+ protected filterLabel = 'Filter';
72
+
73
+ /** Override to define what the lower-cased filter query matches for an item. */
74
+ protected filterMatches(_item: T, _query: string): boolean {
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * The list after applying the active filter — used for both display and
80
+ * navigation. Identical to `getItems()` unless a filter is enabled and a
81
+ * non-empty query is set, so existing panels are unaffected.
82
+ */
83
+ protected getVisibleItems(): readonly T[] {
84
+ const all = this.getItems();
85
+ const q = this.filterQuery.trim().toLowerCase();
86
+ if (!this.filterEnabled || !q) return all;
87
+ return all.filter((item) => this.filterMatches(item, q));
88
+ }
89
+
90
+ /**
91
+ * Filter-mode key handling. Returns `true`/`false` when consumed/ignored in
92
+ * filter context, or `null` to fall through to normal navigation.
93
+ */
94
+ private _handleFilterKey(key: string): boolean | null {
95
+ if (!this.filterEnabled) return null;
96
+ if (this.filterActive) {
97
+ if (key === 'escape') {
98
+ this.filterActive = false;
99
+ this.filterQuery = '';
100
+ this.selectedIndex = 0;
101
+ this.needsRender = true;
102
+ return true;
103
+ }
104
+ if (key === 'return' || key === 'enter') {
105
+ this.filterActive = false; // commit; keep the query applied
106
+ this.needsRender = true;
107
+ return true;
108
+ }
109
+ if (key === 'backspace' || key === 'delete') {
110
+ this.filterQuery = this.filterQuery.slice(0, -1);
111
+ this.selectedIndex = 0;
112
+ this.needsRender = true;
113
+ return true;
114
+ }
115
+ // Arrow/paging keys navigate the filtered list — fall through.
116
+ if (key === 'up' || key === 'down' || key === 'pageup' || key === 'pagedown' || key === 'home' || key === 'end') {
117
+ return null;
118
+ }
119
+ // Any printable character (including j/k/g/G) extends the query.
120
+ if (key.length === 1 && key >= ' ') {
121
+ this.filterQuery += key;
122
+ this.selectedIndex = 0;
123
+ this.needsRender = true;
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+ if (key === '/') {
129
+ this.filterActive = true;
130
+ this.needsRender = true;
131
+ return true;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** The filter input line shown at the top of the list when filtering is enabled. */
137
+ protected buildFilterLine(width: number): Line {
138
+ const palette = this.getPalette();
139
+ const label = this.filterActive ? `[${this.filterLabel}] ` : `${this.filterLabel}: `;
140
+ const value = this.filterActive
141
+ ? `${this.filterQuery}_`
142
+ : this.filterQuery || '(/ to filter)';
143
+ return buildSearchInputLine(width, label, value, palette, {
144
+ active: false,
145
+ bg: this.filterActive ? palette.inputBg : palette.sectionBg,
146
+ valueColor: this.filterActive ? palette.info : (this.filterQuery ? palette.value : palette.dim),
147
+ });
148
+ }
149
+
59
150
  constructor(
60
151
  id: string,
61
152
  name: string,
@@ -143,7 +234,11 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
143
234
  // call this.clearError() at the start of their handler.
144
235
  if (this.lastError !== null) this.clearError();
145
236
 
146
- const items = this.getItems();
237
+ // Opt-in inline filter consumes input before navigation when active.
238
+ const filterResult = this._handleFilterKey(key);
239
+ if (filterResult !== null) return filterResult;
240
+
241
+ const items = this.getVisibleItems();
147
242
  const total = items.length;
148
243
 
149
244
  switch (key) {
@@ -202,7 +297,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
202
297
 
203
298
  handleScroll(deltaRows: number): boolean {
204
299
  if (this.lastError !== null) this.clearError();
205
- const total = this.getItems().length;
300
+ const total = this.getVisibleItems().length;
206
301
  const rows = Math.trunc(deltaRows);
207
302
  if (total === 0 || rows === 0) return false;
208
303
  const next = Math.max(0, Math.min(total - 1, this.selectedIndex + rows));
@@ -221,7 +316,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
221
316
  * Must be called after any data refresh that may shrink the list.
222
317
  */
223
318
  protected clampSelection(): void {
224
- const total = this.getItems().length;
319
+ const total = this.getVisibleItems().length;
225
320
  if (total === 0) {
226
321
  this.selectedIndex = 0;
227
322
  } else {
@@ -253,6 +348,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
253
348
  options: {
254
349
  readonly header?: readonly Line[];
255
350
  readonly footer?: readonly Line[];
351
+ readonly hints?: ReadonlyArray<{ keys: string; label: string }>;
256
352
  readonly emptyMessage?: string;
257
353
  readonly title?: string;
258
354
  readonly spinnerFrame?: number;
@@ -260,19 +356,32 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
260
356
  ): Line[] {
261
357
  this.needsRender = false;
262
358
  const palette = this.getPalette();
263
- const items = this.getItems();
359
+ const items = this.getVisibleItems();
264
360
  const title = options.title ?? this.name;
265
361
 
362
+ // Auto-inject the filter input line at the top of the header when filtering
363
+ // is enabled, so every filterable panel gets the same affordance for free.
364
+ const baseHeader = options.header ? [...options.header as Line[]] : [];
365
+ const header: Line[] | undefined = this.filterEnabled
366
+ ? [this.buildFilterLine(width), ...baseHeader]
367
+ : (options.header ? baseHeader : undefined);
368
+
369
+ // Standardized keyboard-hints footer row (rendered below any explicit footer).
370
+ const hintsLine = options.hints && options.hints.length > 0
371
+ ? buildKeyboardHints(width, options.hints, palette)
372
+ : null;
373
+
266
374
  // I2: inject error line into footer when present
267
375
  const errorLine = this.renderErrorLine(width);
268
376
  const baseFooter = options.footer ? [...options.footer as Line[]] : [];
377
+ if (hintsLine) baseFooter.push(hintsLine);
269
378
  const effectiveFooter: Line[] = errorLine ? [errorLine, ...baseFooter] : baseFooter;
270
379
 
271
380
  // I3: if loading, show spinner in place of normal content
272
381
  const spinnerLine = this.renderLoadingLine(width, options.spinnerFrame ?? 0);
273
382
  if (spinnerLine) {
274
383
  const loadingSection = { lines: [spinnerLine] };
275
- const headerSection = options.header ? [{ lines: options.header as Line[] }] : [];
384
+ const headerSection = header ? [{ lines: header }] : [];
276
385
  const lines = buildPanelWorkspace(width, height, {
277
386
  title,
278
387
  sections: [...headerSection, loadingSection],
@@ -306,17 +415,22 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
306
415
 
307
416
  // Empty state
308
417
  if (scrollableLines.length === 0) {
418
+ // Distinguish "filter hides everything" from "genuinely empty".
419
+ const filteredToEmpty = this.filterEnabled && this.filterQuery.trim() !== '' && this.getItems().length > 0;
420
+ const emptyMessage = filteredToEmpty
421
+ ? ` No matches for "${this.filterQuery.trim()}"`
422
+ : (options.emptyMessage ?? this.getEmptyStateMessage());
309
423
  const emptyLines = buildEmptyState(
310
424
  width,
311
- options.emptyMessage ?? this.getEmptyStateMessage(),
312
- '',
313
- this.getEmptyStateActions(),
425
+ emptyMessage,
426
+ filteredToEmpty ? 'Press Esc to clear the filter.' : '',
427
+ filteredToEmpty ? [] : this.getEmptyStateActions(),
314
428
  palette,
315
429
  );
316
430
  const lines = buildPanelWorkspace(width, height, {
317
431
  title,
318
432
  sections: [
319
- ...(options.header ? [{ lines: options.header as Line[] }] : []),
433
+ ...(header ? [{ lines: header }] : []),
320
434
  { lines: emptyLines },
321
435
  ...(effectiveFooter.length > 0 ? [{ lines: effectiveFooter }] : []),
322
436
  ],
@@ -327,7 +441,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
327
441
  }
328
442
 
329
443
  // Resolve scrollable section (updates scrollStart)
330
- const beforeSections = options.header ? [{ lines: options.header as Line[] }] : [];
444
+ const beforeSections = header ? [{ lines: header }] : [];
331
445
  const afterSections = effectiveFooter.length > 0 ? [{ lines: effectiveFooter }] : [];
332
446
 
333
447
  const resolved = resolveScrollablePanelSection(width, height, {
@@ -1,8 +1,10 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import { truncateDisplay } from '../utils/terminal-width.ts';
2
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
4
  import type { TokenAuditResult } from '@pellux/goodvibes-sdk/platform/security';
4
5
  import type { UiReadModel, UiSecuritySnapshot } from '../runtime/ui-read-models.ts';
5
6
  import {
7
+ buildAlignedRow,
6
8
  buildEmptyState,
7
9
  buildGuidanceLine,
8
10
  buildPanelLine,
@@ -63,6 +65,8 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
63
65
  public constructor(private readonly readModel: UiReadModel<UiSecuritySnapshot>) {
64
66
  super('security', 'Security', 'U', 'monitoring');
65
67
  this.showSelectionGutter = true; // I5: non-color selection affordance
68
+ this.filterEnabled = true;
69
+ this.filterLabel = 'Filter tokens';
66
70
  this.unsub = this.readModel.subscribe(() => this.markDirty());
67
71
  }
68
72
 
@@ -85,24 +89,38 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
85
89
  }
86
90
 
87
91
  protected renderItem(result: TokenAuditResult, index: number, selected: boolean, width: number): Line {
88
- const bg = selected ? C.selectBg : undefined;
89
- return buildPanelLine(width, [
90
- [' ', C.label, bg],
91
- [result.label.padEnd(22), C.value, bg],
92
- [` ${result.tokenId.padEnd(12)}`, C.info, bg],
93
- [` ${result.scope.policyId.padEnd(10)}`, C.label, bg],
94
- [` ${resultSummary(result).slice(0, Math.max(0, width - 49))}`, resultColor(result), bg],
95
- ]);
92
+ return buildAlignedRow(
93
+ width,
94
+ [
95
+ { text: result.label, fg: C.value },
96
+ { text: result.tokenId, fg: C.info },
97
+ { text: result.scope.policyId, fg: C.label },
98
+ { text: resultSummary(result), fg: resultColor(result) },
99
+ ],
100
+ [
101
+ { width: 22 },
102
+ { width: 12 },
103
+ { width: 10 },
104
+ { width: Math.max(8, width - 50) },
105
+ ],
106
+ { selected, selectedBg: C.selectBg },
107
+ );
96
108
  }
97
109
 
98
110
  public handleInput(key: string): boolean {
99
- if (key === 'r') {
111
+ if (!this.filterActive && key === 'r') {
100
112
  this.markDirty();
101
113
  return true;
102
114
  }
103
115
  return super.handleInput(key);
104
116
  }
105
117
 
118
+ protected override filterMatches(result: TokenAuditResult, q: string): boolean {
119
+ return result.label.toLowerCase().includes(q)
120
+ || result.scope.policyId.toLowerCase().includes(q)
121
+ || result.tokenId.toLowerCase().includes(q);
122
+ }
123
+
106
124
  public render(width: number, height: number): Line[] {
107
125
  this.clampSelection();
108
126
  const snapshot = this.readModel.getSnapshot();
@@ -168,7 +186,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
168
186
  [' critical ', C.label],
169
187
  ...buildStatusPill(attackPathReview.incoherentFindings > 0 ? 'warn' : 'good', String(attackPathReview.incoherentFindings)),
170
188
  [' review ', C.label],
171
- [attackPathReview.summary.slice(0, Math.max(0, width - 36)), C.dim],
189
+ [truncateDisplay(attackPathReview.summary, Math.max(0, width - 36)), C.dim],
172
190
  ]),
173
191
  ];
174
192
 
@@ -205,20 +223,41 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
205
223
  attackPathLines.push(buildPanelLine(width, [[' MCP attack-path review', C.label]]));
206
224
  for (const finding of attackPathReview.findings.slice(0, 3)) {
207
225
  attackPathLines.push(buildPanelLine(width, [[
208
- ` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`.slice(0, width),
226
+ truncateDisplay(` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`, width),
209
227
  severityColor(finding.severity),
210
228
  ]]));
211
229
  attackPathLines.push(buildPanelLine(width, [[
212
- ` ${finding.reason}`.slice(0, width),
230
+ truncateDisplay(` ${finding.reason}`, width),
213
231
  C.dim,
214
232
  ]]));
215
233
  attackPathLines.push(buildPanelLine(width, [[
216
- ` evidence: ${finding.evidence.join(' | ')}`.slice(0, width),
234
+ truncateDisplay(` evidence: ${finding.evidence.join(' | ')}`, width),
217
235
  C.dim,
218
236
  ]]));
219
237
  }
220
238
  }
221
239
 
240
+ // Column header for the token audit list so the aligned columns are legible.
241
+ const listHeader: Line[] = [
242
+ ...governanceLines,
243
+ buildAlignedRow(
244
+ width,
245
+ [
246
+ { text: 'TOKEN LABEL', fg: C.label, bold: true },
247
+ { text: 'TOKEN ID', fg: C.label, bold: true },
248
+ { text: 'POLICY', fg: C.label, bold: true },
249
+ { text: `STATUS (${view.results.length} audited)`, fg: C.label, bold: true },
250
+ ],
251
+ [
252
+ { width: 22 },
253
+ { width: 12 },
254
+ { width: 10 },
255
+ { width: Math.max(8, width - 50) },
256
+ ],
257
+ {},
258
+ ),
259
+ ];
260
+
222
261
  const selected = view.results[this.selectedIndex];
223
262
  const detailLines: Line[] = [];
224
263
  if (selected) {
@@ -234,7 +273,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
234
273
  [' Scope: ', C.label],
235
274
  [selected.scope.outcome, selected.scope.outcome === 'violation' ? C.error : C.ok],
236
275
  [' Excess: ', C.label],
237
- [(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none').slice(0, Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.error : C.dim],
276
+ [truncateDisplay(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none', Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.error : C.dim],
238
277
  ]));
239
278
  detailLines.push(buildPanelLine(width, [
240
279
  [' Rotation: ', C.label],
@@ -250,46 +289,59 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
250
289
  ]]));
251
290
  if (preflightStatus !== 'n/a') {
252
291
  detailLines.push(buildPanelLine(width, [[
253
- `Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`.slice(0, width),
292
+ truncateDisplay(`Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`, width),
254
293
  preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : C.dim,
255
294
  ]]));
256
295
  }
257
296
  if (quarantinedMcp.length > 0) {
258
297
  const server = quarantinedMcp[0]!;
259
298
  detailLines.push(buildPanelLine(width, [[
260
- `MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`.slice(0, width),
299
+ truncateDisplay(`MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`, width),
261
300
  C.error,
262
301
  ]]));
263
302
  }
264
303
  if (quarantinedPlugins.length > 0) {
265
304
  const plugin = quarantinedPlugins[0]!;
266
305
  detailLines.push(buildPanelLine(width, [[
267
- `Plugin quarantine: ${plugin.name} (${plugin.trustTier})`.slice(0, width),
306
+ truncateDisplay(`Plugin quarantine: ${plugin.name} (${plugin.trustTier})`, width),
268
307
  C.error,
269
308
  ]]));
270
309
  } else if (untrustedPlugins.length > 0) {
271
310
  const plugin = untrustedPlugins[0]!;
272
311
  detailLines.push(buildPanelLine(width, [[
273
- `Plugin trust warning: ${plugin.name} remains untrusted.`.slice(0, width),
312
+ truncateDisplay(`Plugin trust warning: ${plugin.name} remains untrusted.`, width),
274
313
  C.warn,
275
314
  ]]));
276
315
  }
277
316
  if (latestIncident) {
278
317
  detailLines.push(buildPanelLine(width, [[
279
- `Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`.slice(0, width),
318
+ truncateDisplay(`Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`, width),
280
319
  C.warn,
281
320
  ]]));
282
321
  }
283
322
  }
284
323
 
324
+ const hints = this.filterActive
325
+ ? [
326
+ { keys: 'type', label: 'filter tokens' },
327
+ { keys: 'Enter', label: 'apply' },
328
+ { keys: 'Esc', label: 'clear' },
329
+ ]
330
+ : [
331
+ { keys: '↑/↓', label: 'select token' },
332
+ { keys: '/', label: 'filter' },
333
+ { keys: 'r', label: 'refresh audit' },
334
+ ];
335
+
285
336
  return this.renderList(width, height, {
286
337
  title: 'Security Control Room',
287
- header: governanceLines,
338
+ header: listHeader,
288
339
  footer: [
289
340
  ...detailLines,
290
341
  ...attackPathLines,
291
342
  footerLine,
292
343
  ],
344
+ hints,
293
345
  });
294
346
  }
295
347
  }
@@ -1,5 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
5
  import {
5
6
  type ServiceConfig,
@@ -8,7 +9,9 @@ import {
8
9
  } from '@pellux/goodvibes-sdk/platform/config';
9
10
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
10
11
  import {
11
- buildEmptyState,
12
+ buildDetailBlock,
13
+ buildKeyValueLine,
14
+ buildKeyboardHints,
12
15
  buildPanelLine,
13
16
  buildPanelWorkspace,
14
17
  buildStatusPill,
@@ -80,11 +83,18 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
80
83
  ) {
81
84
  super('services', 'Services', 'V', 'monitoring');
82
85
  this.showSelectionGutter = true; // I5: non-color selection affordance
86
+ this.filterEnabled = true;
87
+ this.filterLabel = 'Filter services';
83
88
  this.registry = registry;
84
89
  this.subscriptionManager = subscriptionManager;
85
90
  void this.refresh();
86
91
  }
87
92
 
93
+ protected override filterMatches(entry: ServicePanelEntry, q: string): boolean {
94
+ return entry.name.toLowerCase().includes(q)
95
+ || (entry.inspection.config.baseUrl ?? '').toLowerCase().includes(q);
96
+ }
97
+
88
98
  public override onActivate(): void {
89
99
  super.onActivate();
90
100
  if (this.entries.length === 0 && !this.loading) {
@@ -112,18 +122,20 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
112
122
  [entry.name.padEnd(16), C.value, bg],
113
123
  [` ${statusLabel(entry).padEnd(12)}`, statusColor(entry), bg],
114
124
  [` ${authSummary(entry.inspection.config, this.subscriptionManager).padEnd(18)}`, C.info, bg],
115
- [` ${entry.inspection.config.baseUrl ?? '(no baseUrl)'}`, C.dim, bg],
125
+ [` ${truncateDisplay(entry.inspection.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 48))}`, C.dim, bg],
116
126
  ]);
117
127
  }
118
128
 
119
129
  public handleInput(key: string): boolean {
120
- if (key === 'r') {
121
- void this.refresh();
122
- return true;
123
- }
124
- if (key === 't') {
125
- void this.testSelected();
126
- return true;
130
+ if (!this.filterActive) {
131
+ if (key === 'r') {
132
+ void this.refresh();
133
+ return true;
134
+ }
135
+ if (key === 't') {
136
+ void this.testSelected();
137
+ return true;
138
+ }
127
139
  }
128
140
  return super.handleInput(key);
129
141
  }
@@ -176,11 +188,29 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
176
188
  return lines;
177
189
  }
178
190
 
191
+ // Health-summary header — surface the most important counts first.
192
+ const counts = { healthy: 0, error: 0, configured: 0, unconfigured: 0 };
193
+ for (const entry of this.entries) {
194
+ const label = statusLabel(entry);
195
+ if (label === 'HEALTHY') counts.healthy++;
196
+ else if (label === 'ERROR') counts.error++;
197
+ else if (label === 'CONFIGURED') counts.configured++;
198
+ else counts.unconfigured++;
199
+ }
200
+ const headerLines: Line[] = [
201
+ buildKeyValueLine(width, [
202
+ { label: 'services', value: String(this.entries.length), valueColor: this.entries.length > 0 ? C.info : C.dim },
203
+ { label: 'healthy', value: String(counts.healthy), valueColor: counts.healthy > 0 ? C.ok : C.dim },
204
+ { label: 'errors', value: String(counts.error), valueColor: counts.error > 0 ? C.error : C.dim },
205
+ { label: 'unconfigured', value: String(counts.unconfigured), valueColor: counts.unconfigured > 0 ? C.warn : C.dim },
206
+ ], C),
207
+ ];
208
+
179
209
  const selected = this.entries[this.selectedIndex];
180
- const detailLines: Line[] = [];
210
+ const detailRows: Line[] = [];
181
211
  if (selected) {
182
212
  const inspect = selected.inspection;
183
- detailLines.push(buildPanelLine(width, [
213
+ detailRows.push(buildPanelLine(width, [
184
214
  [' Service: ', C.label],
185
215
  [selected.name, C.value],
186
216
  [' State: ', C.label],
@@ -188,7 +218,11 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
188
218
  [' Auth: ', C.label],
189
219
  [authSummary(inspect.config, this.subscriptionManager), C.info],
190
220
  ]));
191
- detailLines.push(buildPanelLine(width, [
221
+ detailRows.push(buildPanelLine(width, [
222
+ [' Base URL: ', C.label],
223
+ [truncateDisplay(inspect.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 13)), C.dim],
224
+ ]));
225
+ detailRows.push(buildPanelLine(width, [
192
226
  [' Primary credential: ', C.label],
193
227
  ...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
194
228
  [' Webhook URL: ', C.label],
@@ -199,32 +233,44 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
199
233
  ...buildStatusPill(inspect.hasAppToken ? 'good' : 'info', inspect.hasAppToken ? 'present' : 'missing'),
200
234
  ]));
201
235
  if (selected.lastTest) {
202
- detailLines.push(buildPanelLine(width, [
236
+ detailRows.push(buildPanelLine(width, [
203
237
  [' Last test: ', C.label],
204
238
  ...buildStatusPill(selected.lastTest.ok ? 'good' : 'bad', selected.lastTest.ok ? 'ok' : 'failed'),
205
239
  [' Status: ', C.label],
206
240
  [selected.lastTest.status != null ? String(selected.lastTest.status) : 'n/a', C.value],
207
241
  [' URL: ', C.label],
208
- [(selected.lastTest.testedUrl ?? 'n/a').slice(0, Math.max(0, width - 34)), C.dim],
242
+ [truncateDisplay(selected.lastTest.testedUrl ?? 'n/a', Math.max(0, width - 34)), C.dim],
209
243
  ]));
210
244
  if (selected.lastTest.error) {
211
- detailLines.push(buildPanelLine(width, [
245
+ detailRows.push(buildPanelLine(width, [
212
246
  [' Error: ', C.label],
213
- [selected.lastTest.error.slice(0, Math.max(0, width - 10)), C.error],
247
+ [truncateDisplay(selected.lastTest.error, Math.max(0, width - 10)), C.error],
214
248
  ]));
215
249
  }
216
250
  } else {
217
- detailLines.push(buildPanelLine(width, [[' Press t to test the selected service or r to refresh credential status.', C.dim]]));
251
+ detailRows.push(buildPanelLine(width, [[' Not tested yet press t to run a live connection check.', C.dim]]));
218
252
  }
219
- detailLines.push(buildPanelLine(width, [[' Services resolve credentials through hierarchy-aware secure storage, plaintext fallback policy, and project-local config.', C.dim]]));
220
253
  }
221
254
 
255
+ // Context-aware hints: filter mode swaps to filter-specific keys; the test
256
+ // key only makes sense when there is a service to test.
257
+ const hints = this.filterActive
258
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
259
+ : [
260
+ { keys: 'Up/Down', label: 'move' },
261
+ ...(selected ? [{ keys: 't', label: 'test selected' }] : []),
262
+ { keys: 'r', label: 'refresh' },
263
+ { keys: '/', label: 'filter' },
264
+ ];
265
+
266
+ const footer: Line[] = selected
267
+ ? [...buildDetailBlock(width, `Service · ${selected.name}`, detailRows, C), buildKeyboardHints(width, hints, C)]
268
+ : [buildKeyboardHints(width, hints, C)];
269
+
222
270
  return this.renderList(width, height, {
223
271
  title: 'Service Control Room',
224
- footer: [
225
- ...detailLines,
226
- buildPanelLine(width, [[' Up/Down move t test selected service r refresh inspections', C.dim]]),
227
- ],
272
+ header: headerLines,
273
+ footer,
228
274
  emptyMessage: intro,
229
275
  });
230
276
  }
@@ -9,6 +9,7 @@ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
9
  import type { SessionBrowserQuery } from '../runtime/ui-service-queries.ts';
10
10
  import {
11
11
  buildEmptyState,
12
+ buildKeyboardHints,
12
13
  buildPanelLine,
13
14
  buildSearchInputLine,
14
15
  buildStyledPanelLine,
@@ -18,7 +19,7 @@ import {
18
19
  extendPalette,
19
20
  type PanelWorkspaceSection,
20
21
  } from './polish.ts';
21
- import { truncateDisplay } from '../utils/terminal-width.ts';
22
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
22
23
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
24
  import {
24
25
  getPanelSearchFocusTransition,
@@ -84,6 +85,7 @@ export class SessionBrowserPanel extends BasePanel {
84
85
  private confirm: ConfirmState<string> | null = null;
85
86
  private deleteError = '';
86
87
  private loadError = '';
88
+ private hasLoaded = false;
87
89
  private refreshTimerId: ReturnType<typeof setInterval> | null = null;
88
90
 
89
91
  constructor(
@@ -169,8 +171,15 @@ export class SessionBrowserPanel extends BasePanel {
169
171
  if (height <= 0 || width <= 0) return [];
170
172
  const intro = 'Browse, search, resume, and prune saved conversations.';
171
173
 
174
+ // Lazily load on first render so the panel works even when rendered without
175
+ // onActivate(). The session list is a synchronous source, so this resolves
176
+ // immediately rather than flashing a loading screen.
177
+ if (!this.hasLoaded) this._load();
178
+
172
179
  const count = this.filtered.length;
173
180
  const total = this.sessions.length;
181
+ const hasSelection = this.filtered.length > 0;
182
+ // Search/status line — surfaces the active query or any error.
174
183
  const searchLine = this.searching
175
184
  ? ` Search: ${this.searchQuery}_`
176
185
  : this.loadError
@@ -178,11 +187,21 @@ export class SessionBrowserPanel extends BasePanel {
178
187
  : this.deleteError
179
188
  ? ` Error: ${this.deleteError}`
180
189
  : this.searchQuery
181
- ? ` Filter: ${this.searchQuery} (/ or up at top to edit)`
182
- : ` / or up at top to search Enter: resume d: delete r: refresh`;
190
+ ? ` Filter: ${this.searchQuery}`
191
+ : ` (no filter)`;
183
192
  const statusFg = this.loadError || this.deleteError ? DEFAULT_PANEL_PALETTE.bad : this.searching ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim;
193
+ // Context-aware hints: only advertise keys that work in the current state.
194
+ const hints: Array<{ keys: string; label: string }> = [];
195
+ if (this.searching) {
196
+ hints.push({ keys: 'type', label: 'filter' }, { keys: 'Esc/Enter', label: 'apply' });
197
+ } else {
198
+ hints.push({ keys: '/', label: this.searchQuery ? 'edit search' : 'search' });
199
+ if (hasSelection) hints.push({ keys: 'Enter', label: 'resume' }, { keys: 'd', label: 'delete' });
200
+ hints.push({ keys: 'r', label: 'refresh' });
201
+ }
184
202
  const footerLines = [
185
203
  buildSearchInputLine(width, '', searchLine.trimStart(), DEFAULT_PANEL_PALETTE, { active: this.searching, valueColor: statusFg }),
204
+ buildKeyboardHints(width, hints, DEFAULT_PANEL_PALETTE),
186
205
  ];
187
206
 
188
207
  if (this.confirm) {
@@ -208,12 +227,15 @@ export class SessionBrowserPanel extends BasePanel {
208
227
  const emptyBody = this.searchQuery
209
228
  ? 'Clear or change the current filter to surface saved conversations again.'
210
229
  : 'Conversations are saved automatically. Once you have saved sessions, they appear here for review and resume.';
230
+ const emptyActions = this.searchQuery
231
+ ? [{ command: 'Esc', summary: 'clear the filter and show all sessions' }]
232
+ : [{ command: '/session save', summary: 'name and persist the current conversation' }];
211
233
  return buildPanelWorkspace(width, height, {
212
234
  title: ` Sessions [${count}/${total}]`,
213
235
  intro,
214
236
  sections: [
215
237
  {
216
- lines: buildEmptyState(width, emptyTitle, emptyBody, [], DEFAULT_PANEL_PALETTE),
238
+ lines: buildEmptyState(width, emptyTitle, emptyBody, emptyActions, DEFAULT_PANEL_PALETTE),
217
239
  },
218
240
  ],
219
241
  footerLines,
@@ -303,7 +325,7 @@ export class SessionBrowserPanel extends BasePanel {
303
325
  const bg = isCursor ? C.selectBg : '';
304
326
  const date = shortDate(sess.timestamp);
305
327
  const cnt = String(sess.messageCount).padStart(3) + 'm ';
306
- const model = (sess.model || 'unknown').slice(0, 18).padEnd(18) + ' ';
328
+ const model = fitDisplay(sess.model || 'unknown', 18) + ' ';
307
329
  const prefixLength = 1 + 16 + 1 + 4 + 19;
308
330
  const title = truncateDisplay(sess.title || sess.name || '(untitled)', Math.max(0, width - prefixLength));
309
331
  return buildStyledPanelLine(width, [
@@ -321,10 +343,12 @@ export class SessionBrowserPanel extends BasePanel {
321
343
  this.sessions = this.sessionManager.list();
322
344
  this._filter();
323
345
  this.loadError = '';
346
+ this.hasLoaded = true;
324
347
  this.markDirty();
325
348
  } catch (e) {
326
349
  logger.debug('SessionBrowserPanel._load failed', { error: summarizeError(e) });
327
350
  this.loadError = 'Failed to load sessions';
351
+ this.hasLoaded = true;
328
352
  this.markDirty();
329
353
  }
330
354
  }