@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 { RuntimeTask, TaskLifecycleState } from '@pellux/goodvibes-sdk/platform/runtime/store/domains/tasks';
5
5
  import type { ManagedWorktreeMeta } from '@pellux/goodvibes-sdk/platform/runtime/worktree/registry';
6
6
  import type { UiReadModel, UiTasksSnapshot, UiWorktreeSnapshot } from '../runtime/ui-read-models.ts';
@@ -13,8 +13,6 @@ import {
13
13
  buildSummaryBlock,
14
14
  buildPanelWorkspace,
15
15
  DEFAULT_PANEL_PALETTE,
16
- resolvePrimaryScrollableSection,
17
- type PanelWorkspaceSection,
18
16
  } from './polish.ts';
19
17
 
20
18
  const C = {
@@ -148,12 +146,10 @@ function reviewTaskWorktreeAttachments(
148
146
  });
149
147
  }
150
148
 
151
- export class TasksPanel extends BasePanel {
149
+ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
152
150
  private readonly readModel?: UiReadModel<UiTasksSnapshot>;
153
151
  private readonly worktrees?: UiReadModel<UiWorktreeSnapshot>;
154
152
  private readonly unsubscribers: readonly (() => void)[];
155
- private selectedIndex = 0;
156
- private scrollOffset = 0;
157
153
 
158
154
  public constructor(
159
155
  readModel: UiReadModel<UiTasksSnapshot> | undefined,
@@ -177,39 +173,46 @@ export class TasksPanel extends BasePanel {
177
173
  for (const unsubscribe of this.unsubscribers) unsubscribe();
178
174
  }
179
175
 
176
+ protected override getPalette() { return C; }
177
+ protected override getEmptyStateMessage() { return ' No tasks recorded yet.'; }
178
+ protected override getEmptyStateActions() {
179
+ return [
180
+ { command: '/tasks create', summary: 'create a tracked task from the shell' },
181
+ { command: '/orchestration', summary: 'review graph-native task execution and WRFC flows' },
182
+ ];
183
+ }
184
+
185
+ protected getItems(): readonly RuntimeTask[] {
186
+ if (!this.readModel) return [];
187
+ return sortTasks([...this.readModel.getSnapshot().tasks]);
188
+ }
189
+
190
+ protected renderItem(task: RuntimeTask, index: number, selected: boolean, width: number): Line {
191
+ return buildPanelListRow(width, [
192
+ { text: task.status.padEnd(10), fg: statusColor(task.status) },
193
+ { text: ` ${kindLabel(task.kind).padEnd(12)}`, fg: C.value },
194
+ { text: ` ${task.id.slice(0, 8)} `, fg: C.dim },
195
+ { text: task.title.slice(0, Math.max(0, width - 37)), fg: C.value },
196
+ ], C, { selected });
197
+ }
198
+
180
199
  public handleInput(key: string): boolean {
181
- const tasks = this._tasks();
182
- if (tasks.length === 0) return false;
183
- if (key === 'up' || key === 'k') {
184
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
185
- this.markDirty();
186
- return true;
187
- }
188
- if (key === 'down' || key === 'j') {
189
- this.selectedIndex = Math.min(tasks.length - 1, this.selectedIndex + 1);
190
- this.markDirty();
191
- return true;
192
- }
193
200
  if (key === 'home') {
194
201
  this.selectedIndex = 0;
195
202
  this.markDirty();
196
203
  return true;
197
204
  }
198
205
  if (key === 'end') {
199
- this.selectedIndex = tasks.length - 1;
206
+ const tasks = this.getItems();
207
+ this.selectedIndex = Math.max(0, tasks.length - 1);
200
208
  this.markDirty();
201
209
  return true;
202
210
  }
203
- return false;
204
- }
205
-
206
- private _tasks(): RuntimeTask[] {
207
- if (!this.readModel) return [];
208
- return sortTasks([...this.readModel.getSnapshot().tasks]);
211
+ return super.handleInput(key);
209
212
  }
210
213
 
211
214
  public render(width: number, height: number): Line[] {
212
- this.needsRender = false;
215
+ this.clampSelection();
213
216
  const intro = 'Live task lifecycle, ownership, retries, and result/error details across runtime execution domains.';
214
217
  const footerLines = [buildPanelLine(width, [[' Up/Down move Home/End jump', C.dim]])];
215
218
 
@@ -232,34 +235,7 @@ export class TasksPanel extends BasePanel {
232
235
  return workspace;
233
236
  }
234
237
 
235
- const tasks = this._tasks();
236
- if (tasks.length === 0) {
237
- const workspace = buildPanelWorkspace(width, height, {
238
- title: 'Task Control Room',
239
- intro,
240
- sections: [{
241
- title: 'Overview',
242
- lines: [
243
- buildPanelLine(width, [[' queued:0 running:0 blocked:0 failed:0 completed:0', C.dim]]),
244
- ...buildEmptyState(
245
- width,
246
- ' No tasks recorded yet.',
247
- 'Tasks will appear here as exec, agent, ACP, scheduler, daemon, MCP, plugin, and integration work starts.',
248
- [
249
- { command: '/tasks create', summary: 'create a tracked task from the shell' },
250
- { command: '/orchestration', summary: 'review graph-native task execution and WRFC flows' },
251
- ],
252
- C,
253
- ),
254
- ],
255
- }],
256
- palette: C,
257
- });
258
- while (workspace.length < height) workspace.push(createEmptyLine(width));
259
- return workspace;
260
- }
261
-
262
- this.selectedIndex = Math.min(this.selectedIndex, tasks.length - 1);
238
+ const tasks = this.getItems();
263
239
  const counts = STATUS_ORDER.map((status) => ({
264
240
  status,
265
241
  count: tasks.filter((task) => task.status === status).length,
@@ -270,7 +246,6 @@ export class TasksPanel extends BasePanel {
270
246
  const queuedCount = counts.find((entry) => entry.status === 'queued')?.count ?? 0;
271
247
  const completedCount = counts.find((entry) => entry.status === 'completed')?.count ?? 0;
272
248
 
273
- const selected = tasks[this.selectedIndex]!;
274
249
  const postureLines: Line[] = [
275
250
  buildPanelLine(width, [
276
251
  [' queued ', C.label],
@@ -284,7 +259,11 @@ export class TasksPanel extends BasePanel {
284
259
  [' completed ', C.label],
285
260
  [String(completedCount), completedCount > 0 ? C.completed : C.dim],
286
261
  ]),
287
- buildPanelLine(width, [
262
+ ];
263
+
264
+ const selected = tasks[this.selectedIndex];
265
+ if (selected) {
266
+ postureLines.push(buildPanelLine(width, [
288
267
  [' selected ', C.label],
289
268
  [selected.id, C.info],
290
269
  [' status ', C.label],
@@ -293,156 +272,127 @@ export class TasksPanel extends BasePanel {
293
272
  [selected.kind, C.value],
294
273
  [' owner ', C.label],
295
274
  [selected.owner.slice(0, Math.max(0, width - 46)), C.dim],
296
- ]),
275
+ ]));
276
+ }
277
+ postureLines.push(
297
278
  buildGuidanceLine(width, '/teamwork review', 'inspect task-family posture, archetype metadata, and recovery options for active work', C),
298
279
  buildGuidanceLine(width, '/worktree task <task-id>', 'review worktree ownership, restore, and merge posture for the selected task', C),
299
- ];
300
- const descriptor = selected.description ? parseTaskDescriptor(selected.description) : null;
301
- const detailRows: Line[] = [
302
- buildPanelLine(width, [
280
+ );
281
+
282
+ const detailRows: Line[] = [];
283
+ if (selected) {
284
+ const descriptor = selected.description ? parseTaskDescriptor(selected.description) : null;
285
+ detailRows.push(buildPanelLine(width, [
303
286
  [' Title: ', C.label],
304
287
  [selected.title, C.value],
305
288
  [' Status: ', C.label],
306
289
  [selected.status, statusColor(selected.status)],
307
290
  [' Kind: ', C.label],
308
291
  [selected.kind, C.value],
309
- ]),
310
- buildPanelLine(width, [
292
+ ]));
293
+ detailRows.push(buildPanelLine(width, [
311
294
  [' Owner: ', C.label],
312
295
  [selected.owner, C.value],
313
296
  [' Cancellable: ', C.label],
314
297
  [selected.cancellable ? 'yes' : 'no', selected.cancellable ? C.running : C.failed],
315
298
  [' Queue: ', C.label],
316
299
  [formatWhen(selected.queuedAt), C.dim],
317
- ]),
318
- buildPanelLine(width, [
300
+ ]));
301
+ detailRows.push(buildPanelLine(width, [
319
302
  [' Started: ', C.label],
320
303
  [formatWhen(selected.startedAt), C.dim],
321
304
  [' Ended: ', C.label],
322
305
  [formatWhen(selected.endedAt), C.dim],
323
306
  [' Duration: ', C.label],
324
307
  [formatDuration(selected.startedAt, selected.endedAt), C.dim],
325
- ]),
326
- ];
327
- if (descriptor?.mode || descriptor?.family || descriptor?.source) {
328
- detailRows.push(buildPanelLine(width, [
329
- [' Mode: ', C.label],
330
- [descriptor?.mode ?? 'n/a', C.value],
331
- [' Family: ', C.label],
332
- [descriptor?.family ?? 'n/a', C.info],
333
- [' Source: ', C.label],
334
- [descriptor?.source ?? 'builtin/runtime', C.dim],
335
- ]));
336
- }
337
- if (descriptor?.reviewMode || descriptor?.executionProtocol || descriptor?.template) {
338
- detailRows.push(buildPanelLine(width, [
339
- [' Review: ', C.label],
340
- [descriptor?.reviewMode ?? 'n/a', C.value],
341
- [' Protocol: ', C.label],
342
- [descriptor?.executionProtocol ?? 'n/a', C.value],
343
- [' Template: ', C.label],
344
- [descriptor?.template ?? 'n/a', C.dim],
345
- ]));
346
- }
347
- if (selected.correlationId || selected.turnId) {
348
- detailRows.push(buildPanelLine(width, [
349
- [' Correlation: ', C.label],
350
- [selected.correlationId ?? 'n/a', C.dim],
351
- [' Turn: ', C.label],
352
- [selected.turnId ?? 'n/a', C.dim],
353
- ]));
354
- }
355
- if (selected.parentTaskId || selected.childTaskIds.length > 0) {
356
- detailRows.push(buildPanelLine(width, [
357
- [' Parent: ', C.label],
358
- [selected.parentTaskId ?? 'none', C.dim],
359
- [' Children: ', C.label],
360
- [selected.childTaskIds.length > 0 ? selected.childTaskIds.join(', ') : 'none', C.dim],
361
- ]));
362
- }
363
- const attachedWorktrees = reviewTaskWorktreeAttachments(selected.id, this.worktrees);
364
- if (attachedWorktrees.total > 0) {
365
- detailRows.push(buildPanelLine(width, [
366
- [' Worktrees: ', C.label],
367
- [`${attachedWorktrees.total} tracked`, C.info],
368
- [' Active: ', C.label],
369
- [String(attachedWorktrees.active), attachedWorktrees.active > 0 ? C.running : C.dim],
370
- [' Paused: ', C.label],
371
- [String(attachedWorktrees.paused), attachedWorktrees.paused > 0 ? C.blocked : C.dim],
372
308
  ]));
373
- detailRows.push(buildPanelLine(width, [[
374
- ` Next: /worktree task ${selected.id} /worktree recover task ${selected.id}`,
375
- C.dim,
376
- ]]));
377
- for (const record of attachedWorktrees.records.slice(0, 2)) {
309
+ if (descriptor?.mode || descriptor?.family || descriptor?.source) {
310
+ detailRows.push(buildPanelLine(width, [
311
+ [' Mode: ', C.label],
312
+ [descriptor?.mode ?? 'n/a', C.value],
313
+ [' Family: ', C.label],
314
+ [descriptor?.family ?? 'n/a', C.info],
315
+ [' Source: ', C.label],
316
+ [descriptor?.source ?? 'builtin/runtime', C.dim],
317
+ ]));
318
+ }
319
+ if (descriptor?.reviewMode || descriptor?.executionProtocol || descriptor?.template) {
320
+ detailRows.push(buildPanelLine(width, [
321
+ [' Review: ', C.label],
322
+ [descriptor?.reviewMode ?? 'n/a', C.value],
323
+ [' Protocol: ', C.label],
324
+ [descriptor?.executionProtocol ?? 'n/a', C.value],
325
+ [' Template: ', C.label],
326
+ [descriptor?.template ?? 'n/a', C.dim],
327
+ ]));
328
+ }
329
+ if (selected.correlationId || selected.turnId) {
330
+ detailRows.push(buildPanelLine(width, [
331
+ [' Correlation: ', C.label],
332
+ [selected.correlationId ?? 'n/a', C.dim],
333
+ [' Turn: ', C.label],
334
+ [selected.turnId ?? 'n/a', C.dim],
335
+ ]));
336
+ }
337
+ if (selected.parentTaskId || selected.childTaskIds.length > 0) {
338
+ detailRows.push(buildPanelLine(width, [
339
+ [' Parent: ', C.label],
340
+ [selected.parentTaskId ?? 'none', C.dim],
341
+ [' Children: ', C.label],
342
+ [selected.childTaskIds.length > 0 ? selected.childTaskIds.join(', ') : 'none', C.dim],
343
+ ]));
344
+ }
345
+ const attachedWorktrees = reviewTaskWorktreeAttachments(selected.id, this.worktrees);
346
+ if (attachedWorktrees.total > 0) {
347
+ detailRows.push(buildPanelLine(width, [
348
+ [' Worktrees: ', C.label],
349
+ [`${attachedWorktrees.total} tracked`, C.info],
350
+ [' Active: ', C.label],
351
+ [String(attachedWorktrees.active), attachedWorktrees.active > 0 ? C.running : C.dim],
352
+ [' Paused: ', C.label],
353
+ [String(attachedWorktrees.paused), attachedWorktrees.paused > 0 ? C.blocked : C.dim],
354
+ ]));
378
355
  detailRows.push(buildPanelLine(width, [[
379
- ` ${record.state.padEnd(15)} ${record.path}`.slice(0, Math.max(0, width - 2)),
380
- record.state === 'active' ? C.running : record.state === 'paused' ? C.blocked : C.dim,
356
+ ` Next: /worktree task ${selected.id} /worktree recover task ${selected.id}`,
357
+ C.dim,
381
358
  ]]));
359
+ for (const record of attachedWorktrees.records.slice(0, 2)) {
360
+ detailRows.push(buildPanelLine(width, [[
361
+ ` ${record.state.padEnd(15)} ${record.path}`.slice(0, Math.max(0, width - 2)),
362
+ record.state === 'active' ? C.running : record.state === 'paused' ? C.blocked : C.dim,
363
+ ]]));
364
+ }
365
+ }
366
+ if (selected.retryPolicy) {
367
+ detailRows.push(buildPanelLine(width, [
368
+ [' Retry: ', C.label],
369
+ [`${selected.retryPolicy.currentAttempt}/${selected.retryPolicy.maxAttempts} ${selected.retryPolicy.backoff}`, C.value],
370
+ ]));
371
+ }
372
+ if (selected.error) {
373
+ detailRows.push(buildPanelLine(width, [
374
+ [' Error: ', C.label],
375
+ [selected.error.slice(0, Math.max(0, width - 10)), C.failed],
376
+ ]));
377
+ }
378
+ if (selected.result !== undefined) {
379
+ const resultText = safeJson(selected.result);
380
+ detailRows.push(buildPanelLine(width, [
381
+ [' Result: ', C.label],
382
+ [resultText.slice(0, Math.max(0, width - 11)), C.dim],
383
+ ]));
382
384
  }
383
385
  }
384
- if (selected.retryPolicy) {
385
- detailRows.push(buildPanelLine(width, [
386
- [' Retry: ', C.label],
387
- [`${selected.retryPolicy.currentAttempt}/${selected.retryPolicy.maxAttempts} ${selected.retryPolicy.backoff}`, C.value],
388
- ]));
389
- }
390
- if (selected.error) {
391
- detailRows.push(buildPanelLine(width, [
392
- [' Error: ', C.label],
393
- [selected.error.slice(0, Math.max(0, width - 10)), C.failed],
394
- ]));
395
- }
396
- if (selected.result !== undefined) {
397
- const resultText = safeJson(selected.result);
398
- detailRows.push(buildPanelLine(width, [
399
- [' Result: ', C.label],
400
- [resultText.slice(0, Math.max(0, width - 11)), C.dim],
401
- ]));
402
- }
403
- const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'Task posture', postureLines, C) };
404
- const selectedSection: PanelWorkspaceSection = { lines: buildDetailBlock(width, 'Selected task', detailRows, C) };
405
- const rawTaskLines: Line[] = [];
406
- for (let absolute = 0; absolute < tasks.length; absolute++) {
407
- const task = tasks[absolute]!;
408
- rawTaskLines.push(buildPanelListRow(width, [
409
- { text: task.status.padEnd(10), fg: statusColor(task.status) },
410
- { text: ` ${kindLabel(task.kind).padEnd(12)}`, fg: C.value },
411
- { text: ` ${task.id.slice(0, 8)} `, fg: C.dim },
412
- { text: task.title.slice(0, Math.max(0, width - 37)), fg: C.value },
413
- ], C, { selected: absolute === this.selectedIndex }));
414
- }
415
- const resolvedTasksSection = resolvePrimaryScrollableSection(width, height, {
416
- intro,
417
- footerLines,
418
- palette: C,
419
- beforeSections: [postureSection],
420
- section: {
421
- title: 'Tasks',
422
- scrollableLines: rawTaskLines,
423
- selectedIndex: this.selectedIndex,
424
- scrollOffset: this.scrollOffset,
425
- guardRows: 1,
426
- minRows: 4,
427
- appendWindowSummary: { dimColor: C.dim },
428
- },
429
- afterSections: [selectedSection],
430
- });
431
- this.scrollOffset = resolvedTasksSection.scrollOffset;
432
386
 
433
- const sections: PanelWorkspaceSection[] = [
434
- postureSection,
435
- resolvedTasksSection.section,
436
- selectedSection,
437
- ];
438
- const lines = buildPanelWorkspace(width, height, {
387
+ const headerLines: Line[] = buildSummaryBlock(width, 'Task posture', postureLines, C);
388
+
389
+ return this.renderList(width, height, {
439
390
  title: 'Task Control Room',
440
- intro,
441
- sections,
442
- footerLines,
443
- palette: C,
391
+ header: headerLines,
392
+ footer: [
393
+ ...buildDetailBlock(width, 'Selected task', detailRows, C),
394
+ ...footerLines,
395
+ ],
444
396
  });
445
- while (lines.length < height) lines.push(createEmptyLine(width));
446
- return lines.slice(0, height);
447
397
  }
448
398
  }
@@ -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, UiWatchersSnapshot } 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 = {
@@ -51,11 +50,11 @@ function formatTime(value?: number): string {
51
50
  return new Date(value).toLocaleString();
52
51
  }
53
52
 
54
- export class WatchersPanel extends BasePanel {
53
+ type WatcherEntry = UiWatchersSnapshot['watchers'][number];
54
+
55
+ export class WatchersPanel extends ScrollableListPanel<WatcherEntry> {
55
56
  private readonly readModel?: UiReadModel<UiWatchersSnapshot>;
56
57
  private readonly unsub: (() => void) | null;
57
- private selectedIndex = 0;
58
- private scrollOffset = 0;
59
58
 
60
59
  public constructor(readModel?: UiReadModel<UiWatchersSnapshot>) {
61
60
  super('watchers', 'Watchers', 'W', 'monitoring');
@@ -67,29 +66,38 @@ export class WatchersPanel extends BasePanel {
67
66
  this.unsub?.();
68
67
  }
69
68
 
70
- public handleInput(key: string): boolean {
71
- const watchers = this.watchers();
72
- if (watchers.length === 0) return false;
73
- if (key === 'up' || key === 'k') {
74
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
75
- this.markDirty();
76
- return true;
77
- }
78
- if (key === 'down' || key === 'j') {
79
- this.selectedIndex = Math.min(watchers.length - 1, this.selectedIndex + 1);
80
- this.markDirty();
81
- return true;
82
- }
83
- return false;
69
+ protected override getPalette(): PanelPalette {
70
+ return C;
84
71
  }
85
72
 
86
- private watchers() {
73
+ protected getItems(): readonly WatcherEntry[] {
87
74
  if (!this.readModel) return [];
88
- return [...this.readModel.getSnapshot().watchers];
75
+ return this.readModel.getSnapshot().watchers;
76
+ }
77
+
78
+ protected renderItem(watcher: WatcherEntry, _index: number, selected: boolean, width: number): Line {
79
+ const bg = selected ? C.selectBg : undefined;
80
+ return buildPanelLine(width, [
81
+ [' ', C.label, bg],
82
+ [watcher.state.padEnd(10), stateColor(watcher.state), bg],
83
+ [` ${truncateDisplay(watcher.label, 18).padEnd(18)}`, C.value, bg],
84
+ [` ${String(watcher.sourceStatus ?? 'unknown').padEnd(10)}`, sourceStatusColor(watcher.sourceStatus), bg],
85
+ [` ${truncateDisplay(formatLag(watcher.sourceLagMs), Math.max(0, width - 43))}`, C.dim, bg],
86
+ ]);
87
+ }
88
+
89
+ protected override getEmptyStateMessage(): string {
90
+ return ' No watchers registered.';
91
+ }
92
+
93
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
94
+ return [
95
+ { command: '/schedule list', summary: 'review automation that will consume watcher events' },
96
+ { command: '/services auth-review', summary: 'validate integration credentials before enabling remote watchers' },
97
+ ];
89
98
  }
90
99
 
91
100
  public render(width: number, height: number): Line[] {
92
- this.needsRender = false;
93
101
  const intro = 'Managed watchers and source health used to trigger automation, refresh routes, and surface degraded upstream conditions.';
94
102
 
95
103
  if (!this.readModel) {
@@ -112,130 +120,73 @@ export class WatchersPanel extends BasePanel {
112
120
  }
113
121
 
114
122
  const snapshot = this.readModel.getSnapshot();
115
- const watchers = this.watchers();
116
- const summarySection: PanelWorkspaceSection = {
117
- title: 'Posture',
118
- lines: [
119
- buildKeyValueLine(width, [
120
- { label: 'watchers', value: String(snapshot.totalWatchers), valueColor: snapshot.totalWatchers > 0 ? C.info : C.dim },
121
- { label: 'active', value: String(snapshot.activeWatcherIds.length), valueColor: snapshot.activeWatcherIds.length > 0 ? C.ok : C.dim },
122
- { label: 'degraded', value: String(snapshot.totalDegraded), valueColor: snapshot.totalDegraded > 0 ? C.warn : C.dim },
123
- { label: 'lagged', value: String(snapshot.totalLagged), valueColor: snapshot.totalLagged > 0 ? C.warn : C.dim },
124
- ], C),
125
- buildGuidanceLine(width, '/schedule list', 'verify jobs consuming these sources and use daemon APIs for watcher lifecycle control', C),
126
- ],
127
- };
123
+ const watchers = this.getItems();
124
+
125
+ const headerLines: Line[] = [
126
+ buildKeyValueLine(width, [
127
+ { label: 'watchers', value: String(snapshot.totalWatchers), valueColor: snapshot.totalWatchers > 0 ? C.info : C.dim },
128
+ { label: 'active', value: String(snapshot.activeWatcherIds.length), valueColor: snapshot.activeWatcherIds.length > 0 ? C.ok : C.dim },
129
+ { label: 'degraded', value: String(snapshot.totalDegraded), valueColor: snapshot.totalDegraded > 0 ? C.warn : C.dim },
130
+ { label: 'lagged', value: String(snapshot.totalLagged), valueColor: snapshot.totalLagged > 0 ? C.warn : C.dim },
131
+ ], C),
132
+ buildGuidanceLine(width, '/schedule list', 'verify jobs consuming these sources and use daemon APIs for watcher lifecycle control', C),
133
+ ];
128
134
 
129
135
  if (watchers.length === 0) {
130
- const workspace = buildPanelWorkspace(width, height, {
136
+ return this.renderList(width, height, {
131
137
  title: 'Watchers',
132
- intro,
133
- sections: [
134
- summarySection,
135
- {
136
- lines: buildEmptyState(
137
- width,
138
- ' No watchers registered.',
139
- 'Register daemon watchers or enable polling/integration sources to populate this control room.',
140
- [
141
- { command: '/schedule list', summary: 'review automation that will consume watcher events' },
142
- { command: '/services auth-review', summary: 'validate integration credentials before enabling remote watchers' },
143
- ],
144
- C,
145
- ),
146
- },
147
- ],
148
- palette: C,
138
+ header: headerLines,
139
+ emptyMessage: ' No watchers registered.',
149
140
  });
150
- while (workspace.length < height) workspace.push(createEmptyLine(width));
151
- return workspace;
152
141
  }
153
142
 
154
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, watchers.length - 1));
143
+ this.clampSelection();
155
144
  const selected = watchers[this.selectedIndex]!;
156
145
 
157
- const detailSection: PanelWorkspaceSection = {
158
- title: 'Selected Watcher',
159
- lines: [
160
- buildPanelLine(width, [
161
- [' Watcher: ', C.label],
162
- [selected.label, C.value],
163
- [' Kind: ', C.label],
164
- [selected.kind, C.info],
165
- ]),
166
- buildPanelLine(width, [
167
- [' State: ', C.label],
168
- [selected.state, stateColor(selected.state)],
169
- [' Source: ', C.label],
170
- [selected.source.kind, C.value],
171
- ]),
172
- buildPanelLine(width, [
173
- [' Source status: ', C.label],
174
- [selected.sourceStatus ?? 'unknown', sourceStatusColor(selected.sourceStatus)],
175
- [' Lag: ', C.label],
176
- [formatLag(selected.sourceLagMs), selected.sourceLagMs ? C.warn : C.dim],
177
- ]),
178
- buildPanelLine(width, [
179
- [' Heartbeat: ', C.label],
180
- [formatTime(selected.lastHeartbeatAt), C.dim],
181
- [' Checkpoint: ', C.label],
182
- [truncateDisplay(selected.lastCheckpoint ?? 'n/a', Math.max(0, width - 38)), C.dim],
183
- ]),
184
- ...(selected.degradedReason ? [
185
- buildPanelLine(width, [
186
- [' Reason: ', C.label],
187
- [truncateDisplay(selected.degradedReason, Math.max(0, width - 11)), C.warn],
188
- ]),
189
- ] : []),
190
- ...(selected.lastError ? [
191
- buildPanelLine(width, [
192
- [' Error: ', C.label],
193
- [truncateDisplay(selected.lastError, Math.max(0, width - 10)), C.error],
194
- ]),
195
- ] : []),
196
- ],
197
- };
198
-
199
- const resolvedWatchers = resolvePrimaryScrollableSection(width, height, {
200
- intro,
201
- footerLines: [buildPanelLine(width, [[' Up/Down move through watchers', C.dim]])],
202
- palette: C,
203
- beforeSections: [summarySection],
204
- section: {
205
- title: 'Watchers',
206
- scrollableLines: watchers.map((watcher, absolute) => {
207
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
208
- return buildPanelLine(width, [
209
- [' ', C.label, bg],
210
- [watcher.state.padEnd(10), stateColor(watcher.state), bg],
211
- [` ${truncateDisplay(watcher.label, 18).padEnd(18)}`, C.value, bg],
212
- [` ${String(watcher.sourceStatus ?? 'unknown').padEnd(10)}`, sourceStatusColor(watcher.sourceStatus), bg],
213
- [` ${truncateDisplay(formatLag(watcher.sourceLagMs), Math.max(0, width - 43))}`, C.dim, bg],
214
- ]);
215
- }),
216
- selectedIndex: this.selectedIndex,
217
- scrollOffset: this.scrollOffset,
218
- guardRows: 1,
219
- minRows: 5,
220
- appendWindowSummary: { dimColor: C.dim },
221
- },
222
- afterSections: [detailSection],
223
- });
224
- this.scrollOffset = resolvedWatchers.scrollOffset;
225
-
226
- const sections: PanelWorkspaceSection[] = [
227
- summarySection,
228
- resolvedWatchers.section,
229
- detailSection,
146
+ const footerLines: Line[] = [
147
+ buildPanelLine(width, [
148
+ [' Watcher: ', C.label],
149
+ [selected.label, C.value],
150
+ [' Kind: ', C.label],
151
+ [selected.kind, C.info],
152
+ ]),
153
+ buildPanelLine(width, [
154
+ [' State: ', C.label],
155
+ [selected.state, stateColor(selected.state)],
156
+ [' Source: ', C.label],
157
+ [selected.source.kind, C.value],
158
+ ]),
159
+ buildPanelLine(width, [
160
+ [' Source status: ', C.label],
161
+ [selected.sourceStatus ?? 'unknown', sourceStatusColor(selected.sourceStatus)],
162
+ [' Lag: ', C.label],
163
+ [formatLag(selected.sourceLagMs), selected.sourceLagMs ? C.warn : C.dim],
164
+ ]),
165
+ buildPanelLine(width, [
166
+ [' Heartbeat: ', C.label],
167
+ [formatTime(selected.lastHeartbeatAt), C.dim],
168
+ [' Checkpoint: ', C.label],
169
+ [truncateDisplay(selected.lastCheckpoint ?? 'n/a', Math.max(0, width - 38)), C.dim],
170
+ ]),
230
171
  ];
231
- const lines = buildPanelWorkspace(width, height, {
172
+ if (selected.degradedReason) {
173
+ footerLines.push(buildPanelLine(width, [
174
+ [' Reason: ', C.label],
175
+ [truncateDisplay(selected.degradedReason, Math.max(0, width - 11)), C.warn],
176
+ ]));
177
+ }
178
+ if (selected.lastError) {
179
+ footerLines.push(buildPanelLine(width, [
180
+ [' Error: ', C.label],
181
+ [truncateDisplay(selected.lastError, Math.max(0, width - 10)), C.error],
182
+ ]));
183
+ }
184
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through watchers', C.dim]]));
185
+
186
+ return this.renderList(width, height, {
232
187
  title: 'Watchers',
233
- intro,
234
- sections,
235
- footerLines: [buildPanelLine(width, [[' Up/Down move through watchers', C.dim]])],
236
- palette: C,
188
+ header: headerLines,
189
+ footer: footerLines,
237
190
  });
238
- while (lines.length < height) lines.push(createEmptyLine(width));
239
- return lines.slice(0, height);
240
191
  }
241
192
  }