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