@pellux/goodvibes-tui 0.18.20 → 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 +120 -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.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 -82
- package/src/panels/automation-control-panel.ts +119 -161
- package/src/panels/communication-panel.ts +68 -107
- package/src/panels/control-plane-panel.ts +116 -172
- package/src/panels/hooks-panel.ts +101 -138
- package/src/panels/incident-review-panel.ts +55 -107
- package/src/panels/local-auth-panel.ts +76 -93
- 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 +45 -14
- 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/subscription-panel.ts +63 -86
- 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/version.ts +1 -1
|
@@ -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,
|
|
@@ -250,6 +258,23 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
|
|
|
250
258
|
this.renderItem(item, index, index === this.selectedIndex, width),
|
|
251
259
|
);
|
|
252
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
|
+
|
|
253
278
|
// Empty state
|
|
254
279
|
if (scrollableLines.length === 0) {
|
|
255
280
|
const emptyLines = buildEmptyState(
|
|
@@ -411,21 +436,27 @@ export abstract class SearchableListPanel<T> extends ScrollableListPanel<T> {
|
|
|
411
436
|
}
|
|
412
437
|
|
|
413
438
|
/**
|
|
414
|
-
* Build the
|
|
439
|
+
* Build the filter input `Line` for use in a panel header section.
|
|
415
440
|
*
|
|
416
|
-
*
|
|
417
|
-
* `this.searchQuery`. Convenience wrapper:
|
|
441
|
+
* Renders the filter label and current query with context-sensitive formatting:
|
|
418
442
|
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* private buildHeader(width: number): Line[] {
|
|
423
|
-
* return [buildSearchInputLine(width, 'Filter', this.searchQuery, this.getPalette(), {})];
|
|
424
|
-
* }
|
|
425
|
-
* ```
|
|
443
|
+
* - **Focused** (`focused = true`): `[Filter] query_` — active, bold, cursor visible
|
|
444
|
+
* - **Unfocused** (`focused = false`): `Filter: query` — dim, no cursor
|
|
426
445
|
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
446
|
+
* @param width Panel width in columns.
|
|
447
|
+
* @param label Label text (default: `'Filter'`).
|
|
448
|
+
* @param focused Whether the filter input is currently active.
|
|
430
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
|
+
}
|
|
431
462
|
}
|