@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-rendering.ts +20 -6
  5. package/src/input/commands/session.ts +0 -1
  6. package/src/input/feed-context-factory.ts +236 -0
  7. package/src/input/handler-feed.ts +44 -6
  8. package/src/input/handler-shortcuts.ts +138 -125
  9. package/src/input/handler.ts +121 -119
  10. package/src/input/keybindings.ts +30 -0
  11. package/src/panels/approval-panel.ts +54 -82
  12. package/src/panels/automation-control-panel.ts +119 -161
  13. package/src/panels/communication-panel.ts +68 -107
  14. package/src/panels/control-plane-panel.ts +116 -172
  15. package/src/panels/hooks-panel.ts +101 -138
  16. package/src/panels/incident-review-panel.ts +55 -107
  17. package/src/panels/local-auth-panel.ts +76 -93
  18. package/src/panels/mcp-panel.ts +108 -155
  19. package/src/panels/ops-control-panel.ts +50 -85
  20. package/src/panels/panel-manager.ts +22 -2
  21. package/src/panels/plugins-panel.ts +36 -60
  22. package/src/panels/routes-panel.ts +89 -141
  23. package/src/panels/scrollable-list-panel.ts +45 -14
  24. package/src/panels/security-panel.ts +101 -137
  25. package/src/panels/services-panel.ts +58 -102
  26. package/src/panels/settings-sync-panel.ts +76 -122
  27. package/src/panels/subscription-panel.ts +63 -86
  28. package/src/panels/tasks-panel.ts +129 -179
  29. package/src/panels/watchers-panel.ts +88 -137
  30. package/src/renderer/buffer.ts +11 -0
  31. package/src/renderer/diff.ts +8 -0
  32. package/src/renderer/help-overlay.ts +37 -28
  33. package/src/renderer/markdown.ts +3 -145
  34. 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 { BasePanel } from './base-panel.ts';
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
- resolvePrimaryScrollableSection,
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
- export class RoutesPanel extends BasePanel {
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
- public handleInput(key: string): boolean {
50
- const bindings = this.bindings();
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
- private bindings() {
52
+ protected getItems(): readonly RouteBinding[] {
66
53
  if (!this.readModel) return [];
67
- return [...this.readModel.getSnapshot().bindings];
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.bindings();
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 summarySection: PanelWorkspaceSection = {
100
- title: 'Posture',
101
- lines: [
102
- buildKeyValueLine(width, [
103
- { label: 'bindings', value: String(snapshot.totalBindings), valueColor: snapshot.totalBindings > 0 ? C.info : C.dim },
104
- { label: 'active', value: String(snapshot.activeBindingIds.length), valueColor: snapshot.activeBindingIds.length > 0 ? C.ok : C.dim },
105
- { label: 'resolved', value: String(snapshot.totalResolved), valueColor: snapshot.totalResolved > 0 ? C.ok : C.dim },
106
- { label: 'failures', value: String(snapshot.totalFailures), valueColor: snapshot.totalFailures > 0 ? C.error : C.dim },
107
- ], C),
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
- const workspace = buildPanelWorkspace(width, height, {
118
+ return this.renderList(width, height, {
114
119
  title: 'Route Bindings',
115
- intro,
116
- sections: [
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.selectedIndex = Math.min(this.selectedIndex, Math.max(0, bindings.length - 1));
125
+ this.clampSelection();
138
126
  const selected = bindings[this.selectedIndex]!;
139
127
 
140
- const surfaceSection: PanelWorkspaceSection = {
141
- title: 'Surfaces',
142
- lines: surfaceEntries.length > 0
143
- ? surfaceEntries.slice(0, 6).map(([surface, ids]) => buildPanelLine(width, [
144
- [' ', C.label],
145
- [surface.padEnd(10), C.info],
146
- [` ${String(ids.length)} binding(s)`, C.value],
147
- ]))
148
- : [buildPanelLine(width, [[' No surface counts recorded.', C.dim]])],
149
- };
150
-
151
- const detailSection: PanelWorkspaceSection = {
152
- title: 'Selected Binding',
153
- lines: [
154
- buildPanelLine(width, [
155
- [' Binding: ', C.label],
156
- [selected.id, C.value],
157
- [' Surface: ', C.label],
158
- [selected.surfaceKind, C.info],
159
- ]),
160
- buildPanelLine(width, [
161
- [' External: ', C.label],
162
- [truncateDisplay(selected.externalId, 28), C.value],
163
- [' Kind: ', C.label],
164
- [selected.kind, C.dim],
165
- ]),
166
- buildPanelLine(width, [
167
- [' Session: ', C.label],
168
- [selected.sessionId ?? 'n/a', C.value],
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
- const lines = buildPanelWorkspace(width, height, {
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
- intro,
221
- sections,
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 search input `Line` suitable for use in a panel header.
439
+ * Build the filter input `Line` for use in a panel header section.
415
440
  *
416
- * Import `buildSearchInputLine` from `./polish.ts` and call it with
417
- * `this.searchQuery`. Convenience wrapper:
441
+ * Renders the filter label and current query with context-sensitive formatting:
418
442
  *
419
- * ```ts
420
- * import { buildSearchInputLine } from './polish.ts';
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
- * 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.
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
  }