@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.
- package/CHANGELOG.md +154 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -3
- package/src/core/conversation-rendering.ts +22 -6
- package/src/core/orchestrator.ts +1 -1
- package/src/input/commands/diff-runtime.ts +6 -5
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +2 -2
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/session-content.ts +1 -1
- package/src/input/commands/session.ts +0 -1
- package/src/input/commands/shell-core.ts +3 -2
- package/src/input/commands/skills-runtime.ts +2 -2
- package/src/input/commands/subscription-runtime.ts +4 -4
- package/src/input/feed-context-factory.ts +236 -0
- package/src/input/handler-feed.ts +44 -6
- package/src/input/handler-shortcuts.ts +138 -125
- package/src/input/handler.ts +119 -119
- package/src/input/keybindings.ts +30 -0
- package/src/input/panel-integration-actions.ts +2 -1
- package/src/input/settings-modal-types.ts +60 -0
- package/src/input/settings-modal.ts +83 -65
- package/src/panels/agent-inspector-panel.ts +10 -9
- package/src/panels/agent-logs-panel.ts +26 -6
- package/src/panels/approval-panel.ts +55 -82
- package/src/panels/automation-control-panel.ts +120 -161
- package/src/panels/base-panel.ts +108 -3
- package/src/panels/communication-panel.ts +69 -107
- package/src/panels/context-visualizer-panel.ts +2 -0
- package/src/panels/control-plane-panel.ts +117 -172
- package/src/panels/diff-panel.ts +2 -0
- package/src/panels/file-explorer-panel.ts +51 -31
- package/src/panels/file-preview-panel.ts +57 -35
- package/src/panels/git-panel.ts +12 -13
- package/src/panels/hooks-panel.ts +103 -138
- package/src/panels/incident-review-panel.ts +59 -109
- package/src/panels/knowledge-panel.ts +75 -107
- package/src/panels/local-auth-panel.ts +77 -93
- package/src/panels/marketplace-panel.ts +51 -69
- package/src/panels/mcp-panel.ts +110 -155
- package/src/panels/memory-panel.ts +90 -158
- package/src/panels/ops-control-panel.ts +51 -85
- package/src/panels/orchestration-panel.ts +70 -51
- package/src/panels/panel-list-panel.ts +5 -4
- package/src/panels/panel-manager.ts +25 -2
- package/src/panels/plan-dashboard-panel.ts +2 -0
- package/src/panels/plugins-panel.ts +37 -60
- package/src/panels/polish.ts +51 -2
- package/src/panels/provider-accounts-panel.ts +1 -0
- package/src/panels/provider-health-panel.ts +6 -8
- package/src/panels/routes-panel.ts +91 -141
- package/src/panels/schedule-panel.ts +7 -6
- package/src/panels/scrollable-list-panel.ts +64 -16
- package/src/panels/security-panel.ts +118 -152
- package/src/panels/services-panel.ts +63 -105
- package/src/panels/session-browser-panel.ts +19 -18
- package/src/panels/settings-sync-panel.ts +79 -123
- package/src/panels/skills-panel.ts +114 -230
- package/src/panels/subscription-panel.ts +64 -86
- package/src/panels/system-messages-panel.ts +147 -141
- package/src/panels/tasks-panel.ts +130 -179
- package/src/panels/token-budget-panel.ts +2 -0
- package/src/panels/watchers-panel.ts +89 -137
- package/src/panels/worktree-panel.ts +1 -0
- package/src/panels/wrfc-panel.ts +2 -0
- package/src/renderer/agent-detail-modal.ts +2 -2
- package/src/renderer/ansi-sanitize.ts +76 -0
- package/src/renderer/buffer.ts +23 -1
- package/src/renderer/diff.ts +8 -0
- package/src/renderer/help-overlay.ts +48 -28
- package/src/renderer/markdown.ts +3 -145
- package/src/renderer/settings-modal-helpers.ts +27 -0
- package/src/renderer/settings-modal.ts +18 -1
- package/src/renderer/status-glyphs.ts +21 -0
- package/src/renderer/status-token.ts +4 -8
- package/src/renderer/tool-call.ts +4 -3
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/bootstrap-hook-bridge.ts +1 -1
- package/src/runtime/bootstrap.ts +7 -8
- package/src/runtime/diagnostics/panels/policy.ts +2 -1
- package/src/shell/ui-openers.ts +1 -1
- 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
|
|
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
|
-
|
|
465
|
-
|
|
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.
|
|
474
|
-
this.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
+
protected getItems(): readonly RouteBinding[] {
|
|
66
55
|
if (!this.readModel) return [];
|
|
67
|
-
return
|
|
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.
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
120
|
+
return this.renderList(width, height, {
|
|
114
121
|
title: 'Route Bindings',
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
127
|
+
this.clampSelection();
|
|
138
128
|
const selected = bindings[this.selectedIndex]!;
|
|
139
129
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
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.
|
|
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.
|
|
117
|
-
|
|
118
|
-
this.
|
|
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
|
|
119
|
-
|
|
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
|
|
456
|
+
* Build the filter input `Line` for use in a panel header section.
|
|
415
457
|
*
|
|
416
|
-
*
|
|
417
|
-
* `this.searchQuery`. Convenience wrapper:
|
|
458
|
+
* Renders the filter label and current query with context-sensitive formatting:
|
|
418
459
|
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
460
|
+
* - **Focused** (`focused = true`): `[Filter] query_` — active, bold, cursor visible
|
|
461
|
+
* - **Unfocused** (`focused = false`): `Filter: query` — dim, no cursor
|
|
421
462
|
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
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
|
}
|