@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.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/handler-feed-routes.ts +61 -4
- package/src/input/handler-feed.ts +13 -0
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/keybindings.ts +5 -0
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +10 -0
- package/src/main.ts +0 -1
- package/src/panels/agent-inspector-panel.ts +18 -13
- package/src/panels/agent-inspector-shared.ts +3 -3
- package/src/panels/agent-logs-panel.ts +22 -53
- package/src/panels/approval-panel.ts +146 -95
- package/src/panels/automation-control-panel.ts +24 -1
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-panel.ts +24 -5
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +108 -43
- package/src/panels/context-visualizer-panel.ts +25 -1
- package/src/panels/control-plane-panel.ts +27 -1
- package/src/panels/cost-tracker-panel.ts +82 -54
- package/src/panels/debug-panel.ts +51 -16
- package/src/panels/diff-panel.ts +59 -30
- package/src/panels/docs-panel.ts +19 -3
- package/src/panels/eval-panel.ts +130 -81
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +40 -41
- package/src/panels/file-preview-panel.ts +9 -2
- package/src/panels/forensics-panel.ts +27 -13
- package/src/panels/git-panel.ts +58 -70
- package/src/panels/hooks-panel.ts +36 -2
- package/src/panels/incident-review-panel.ts +31 -10
- package/src/panels/intelligence-panel.ts +152 -71
- package/src/panels/knowledge-graph-panel.ts +89 -27
- package/src/panels/local-auth-panel.ts +17 -11
- package/src/panels/marketplace-panel.ts +31 -12
- package/src/panels/memory-panel.ts +5 -4
- package/src/panels/ops-control-panel.ts +69 -7
- package/src/panels/ops-strategy-panel.ts +61 -43
- package/src/panels/orchestration-panel.ts +34 -4
- package/src/panels/panel-list-panel.ts +33 -8
- package/src/panels/panel-manager.ts +23 -4
- package/src/panels/plan-dashboard-panel.ts +183 -66
- package/src/panels/plugins-panel.ts +48 -10
- package/src/panels/policy-panel.ts +60 -23
- package/src/panels/polish-core.ts +157 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +44 -145
- package/src/panels/project-planning-panel.ts +22 -0
- package/src/panels/provider-accounts-panel.ts +55 -18
- package/src/panels/provider-health-panel.ts +65 -23
- package/src/panels/provider-stats-panel.ts +47 -22
- package/src/panels/qr-panel.ts +23 -11
- package/src/panels/remote-panel.ts +12 -5
- package/src/panels/routes-panel.ts +27 -5
- package/src/panels/sandbox-panel.ts +62 -27
- package/src/panels/schedule-panel.ts +40 -17
- package/src/panels/scrollable-list-panel.ts +124 -10
- package/src/panels/security-panel.ts +72 -20
- package/src/panels/services-panel.ts +68 -22
- package/src/panels/session-browser-panel.ts +29 -5
- package/src/panels/session-maintenance.ts +2 -2
- package/src/panels/settings-sync-panel.ts +30 -15
- package/src/panels/subscription-panel.ts +21 -3
- package/src/panels/symbol-outline-panel.ts +40 -47
- package/src/panels/system-messages-panel.ts +60 -27
- package/src/panels/tasks-panel.ts +34 -7
- package/src/panels/thinking-panel.ts +26 -25
- package/src/panels/token-budget-panel.ts +49 -38
- package/src/panels/tool-inspector-panel.ts +27 -21
- package/src/panels/types.ts +25 -0
- package/src/panels/watchers-panel.ts +25 -5
- package/src/panels/work-plan-panel.ts +125 -34
- package/src/panels/worktree-panel.ts +65 -20
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +31 -127
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/model-picker-overlay.ts +9 -4
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/progress.ts +3 -2
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/ui-factory.ts +4 -6
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/version.ts +1 -1
- package/src/panels/panel-picker.ts +0 -106
- package/src/renderer/panel-picker-overlay.ts +0 -202
- 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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
...(
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
[
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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}
|
|
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}
|
|
230
|
+
truncateDisplay(` ${finding.reason}`, width),
|
|
213
231
|
C.dim,
|
|
214
232
|
]]));
|
|
215
233
|
attackPathLines.push(buildPanelLine(width, [[
|
|
216
|
-
` evidence: ${finding.evidence.join(' | ')}
|
|
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'
|
|
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'})
|
|
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}` : ''}
|
|
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})
|
|
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
|
|
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}
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
210
|
+
const detailRows: Line[] = [];
|
|
181
211
|
if (selected) {
|
|
182
212
|
const inspect = selected.inspection;
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
242
|
+
[truncateDisplay(selected.lastTest.testedUrl ?? 'n/a', Math.max(0, width - 34)), C.dim],
|
|
209
243
|
]));
|
|
210
244
|
if (selected.lastTest.error) {
|
|
211
|
-
|
|
245
|
+
detailRows.push(buildPanelLine(width, [
|
|
212
246
|
[' Error: ', C.label],
|
|
213
|
-
[selected.lastTest.error
|
|
247
|
+
[truncateDisplay(selected.lastTest.error, Math.max(0, width - 10)), C.error],
|
|
214
248
|
]));
|
|
215
249
|
}
|
|
216
250
|
} else {
|
|
217
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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}
|
|
182
|
-
: `
|
|
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,
|
|
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'
|
|
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
|
}
|