@pellux/goodvibes-tui 0.28.0 → 0.29.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/input/commands/cost-runtime.ts +49 -0
  5. package/src/input/commands/operator-runtime.ts +5 -1
  6. package/src/input/commands.ts +2 -0
  7. package/src/input/handler-feed-routes.ts +12 -35
  8. package/src/input/handler-feed.ts +4 -8
  9. package/src/input/handler-shortcuts.ts +51 -0
  10. package/src/input/handler.ts +43 -6
  11. package/src/input/keybindings.ts +48 -8
  12. package/src/input/panel-integration-actions.ts +107 -1
  13. package/src/main.ts +5 -1
  14. package/src/panels/agent-inspector-panel.ts +125 -36
  15. package/src/panels/agent-inspector-shared.ts +144 -0
  16. package/src/panels/approval-panel.ts +67 -16
  17. package/src/panels/automation-control-panel.ts +368 -124
  18. package/src/panels/builtin/agent.ts +28 -37
  19. package/src/panels/builtin/development.ts +40 -32
  20. package/src/panels/builtin/knowledge.ts +15 -6
  21. package/src/panels/builtin/operations.ts +178 -117
  22. package/src/panels/builtin/session.ts +35 -9
  23. package/src/panels/builtin/shared.ts +98 -6
  24. package/src/panels/cockpit-panel.ts +232 -73
  25. package/src/panels/communication-panel.ts +58 -20
  26. package/src/panels/confirm-state.ts +8 -1
  27. package/src/panels/control-plane-panel.ts +351 -118
  28. package/src/panels/cost-tracker-panel.ts +165 -7
  29. package/src/panels/debug-panel.ts +312 -159
  30. package/src/panels/diff-panel.ts +203 -57
  31. package/src/panels/docs-panel.ts +152 -66
  32. package/src/panels/eval-panel.ts +220 -42
  33. package/src/panels/file-explorer-panel.ts +202 -122
  34. package/src/panels/file-preview-panel.ts +132 -57
  35. package/src/panels/git-panel.ts +309 -128
  36. package/src/panels/hooks-panel.ts +150 -22
  37. package/src/panels/incident-review-panel.ts +223 -35
  38. package/src/panels/index.ts +0 -4
  39. package/src/panels/intelligence-panel.ts +212 -86
  40. package/src/panels/knowledge-graph-panel.ts +461 -101
  41. package/src/panels/local-auth-panel.ts +240 -28
  42. package/src/panels/marketplace-panel.ts +193 -26
  43. package/src/panels/memory-panel.ts +78 -77
  44. package/src/panels/ops-control-panel.ts +146 -29
  45. package/src/panels/ops-strategy-panel.ts +72 -4
  46. package/src/panels/orchestration-panel.ts +231 -69
  47. package/src/panels/panel-list-panel.ts +154 -131
  48. package/src/panels/panel-manager.ts +97 -9
  49. package/src/panels/plan-dashboard-panel.ts +333 -17
  50. package/src/panels/plugins-panel.ts +184 -29
  51. package/src/panels/policy-panel.ts +210 -38
  52. package/src/panels/polish-core.ts +7 -2
  53. package/src/panels/polish.ts +23 -4
  54. package/src/panels/project-planning-answer-actions.ts +134 -0
  55. package/src/panels/project-planning-panel.ts +62 -113
  56. package/src/panels/provider-health-panel.ts +434 -518
  57. package/src/panels/provider-health-routes.ts +203 -0
  58. package/src/panels/provider-health-tracker.ts +194 -6
  59. package/src/panels/provider-health-views.ts +560 -0
  60. package/src/panels/qr-panel.ts +116 -30
  61. package/src/panels/remote-panel.ts +114 -36
  62. package/src/panels/routes-panel.ts +63 -22
  63. package/src/panels/sandbox-panel.ts +174 -42
  64. package/src/panels/scrollable-list-panel.ts +19 -135
  65. package/src/panels/security-panel.ts +133 -33
  66. package/src/panels/services-panel.ts +116 -64
  67. package/src/panels/session-browser-panel.ts +73 -2
  68. package/src/panels/session-maintenance.ts +4 -122
  69. package/src/panels/settings-sync-panel.ts +335 -72
  70. package/src/panels/skills-panel.ts +157 -89
  71. package/src/panels/subscription-panel.ts +86 -33
  72. package/src/panels/symbol-outline-panel.ts +248 -108
  73. package/src/panels/system-messages-panel.ts +114 -13
  74. package/src/panels/tasks-panel.ts +326 -139
  75. package/src/panels/thinking-panel.ts +43 -10
  76. package/src/panels/token-budget-panel.ts +194 -18
  77. package/src/panels/tool-inspector-panel.ts +144 -34
  78. package/src/panels/types.ts +40 -4
  79. package/src/panels/work-plan-panel.ts +280 -17
  80. package/src/panels/worktree-panel.ts +175 -42
  81. package/src/panels/wrfc-panel.ts +116 -24
  82. package/src/renderer/conversation-overlays.ts +25 -11
  83. package/src/renderer/footer-tips.ts +41 -0
  84. package/src/renderer/fullscreen-primitives.ts +22 -16
  85. package/src/renderer/help-overlay.ts +91 -14
  86. package/src/renderer/hint-grammar.ts +52 -0
  87. package/src/renderer/layout.ts +7 -7
  88. package/src/renderer/modal-factory.ts +23 -15
  89. package/src/renderer/model-picker-overlay.ts +21 -7
  90. package/src/renderer/overlay-box.ts +16 -10
  91. package/src/renderer/process-indicator.ts +3 -1
  92. package/src/renderer/progress.ts +5 -4
  93. package/src/renderer/search-overlay.ts +27 -6
  94. package/src/renderer/session-picker-modal.ts +6 -4
  95. package/src/renderer/settings-modal.ts +70 -10
  96. package/src/renderer/shell-surface.ts +41 -15
  97. package/src/renderer/status-glyphs.ts +11 -9
  98. package/src/renderer/theme.ts +60 -3
  99. package/src/renderer/ui-factory.ts +41 -30
  100. package/src/renderer/ui-primitives.ts +23 -2
  101. package/src/runtime/bootstrap-shell.ts +35 -18
  102. package/src/runtime/diagnostics/panels/index.ts +0 -3
  103. package/src/shell/ui-openers.ts +14 -22
  104. package/src/utils/format-duration.ts +2 -2
  105. package/src/utils/splash-lines.ts +44 -3
  106. package/src/version.ts +1 -1
  107. package/src/work-plans/work-plan-store.ts +13 -0
  108. package/src/panels/agent-logs-panel.ts +0 -635
  109. package/src/panels/agent-logs-shared.ts +0 -129
  110. package/src/panels/context-visualizer-panel.ts +0 -238
  111. package/src/panels/forensics-panel.ts +0 -378
  112. package/src/panels/provider-account-snapshot.ts +0 -259
  113. package/src/panels/provider-accounts-panel.ts +0 -255
  114. package/src/panels/provider-stats-panel.ts +0 -391
  115. package/src/panels/schedule-panel.ts +0 -365
  116. package/src/panels/watchers-panel.ts +0 -213
  117. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  118. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,30 +1,29 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
- import type { UiAutomationSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
4
+ import type { UiAutomationSnapshot, UiReadModel, UiWatchersSnapshot } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
+ import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automation';
7
+ import type { AutomationScheduleDefinition } from '@pellux/goodvibes-sdk/platform/automation';
8
+ import type { WatcherRegistry } from '@pellux/goodvibes-sdk/platform/watchers';
6
9
  import {
10
+ type ConfirmState,
11
+ handleConfirmInput,
12
+ renderConfirmLines,
13
+ } from './confirm-state.ts';
14
+ import {
15
+ buildDetailBlock,
7
16
  buildEmptyState,
8
- buildGuidanceLine,
9
17
  buildKeyValueLine,
10
- buildKeyboardHints,
11
18
  buildPanelLine,
12
19
  buildPanelWorkspace,
13
- buildSectionHeader,
14
20
  DEFAULT_PANEL_PALETTE,
15
21
  type PanelPalette,
16
22
  } from './polish.ts';
17
23
 
18
- const C = {
19
- ...DEFAULT_PANEL_PALETTE,
20
- header: '#94a3b8',
21
- headerBg: '#1e293b',
22
- ok: '#22c55e',
23
- warn: '#eab308',
24
- error: '#ef4444',
25
- info: '#38bdf8',
26
- selectBg: '#0f172a',
27
- } as const;
24
+ // Base chrome only — title band, state colors, and text tokens all come
25
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
26
+ const C = DEFAULT_PANEL_PALETTE;
28
27
 
29
28
  function formatTime(value?: number): string {
30
29
  if (!value) return 'n/a';
@@ -32,26 +31,123 @@ function formatTime(value?: number): string {
32
31
  }
33
32
 
34
33
  function runStatusColor(status: string): string {
35
- if (status === 'completed') return C.ok;
36
- if (status === 'failed' || status === 'dead_lettered') return C.error;
34
+ if (status === 'completed') return C.good;
35
+ if (status === 'failed' || status === 'dead_lettered') return C.bad;
37
36
  if (status === 'cancelled') return C.warn;
38
37
  return C.info;
39
38
  }
40
39
 
40
+ function stateColor(state: string): string {
41
+ if (state === 'running') return C.good;
42
+ if (state === 'degraded' || state === 'starting') return C.warn;
43
+ if (state === 'failed') return C.bad;
44
+ return C.dim;
45
+ }
46
+
47
+ function sourceStatusColor(state?: string): string {
48
+ if (state === 'healthy') return C.good;
49
+ if (state === 'lagging' || state === 'stale' || state === 'degraded') return C.warn;
50
+ if (state === 'failed') return C.bad;
51
+ return C.dim;
52
+ }
53
+
54
+ function formatLag(value?: number): string {
55
+ if (!value || value <= 0) return 'n/a';
56
+ if (value < 1000) return `${value}ms`;
57
+ if (value < 60_000) return `${Math.round(value / 1000)}s`;
58
+ return `${Math.round(value / 60_000)}m`;
59
+ }
60
+
61
+ function formatEveryInterval(intervalMs: number): string {
62
+ const units: ReadonlyArray<readonly [number, string]> = [
63
+ [86_400_000, 'd'],
64
+ [3_600_000, 'h'],
65
+ [60_000, 'm'],
66
+ [1_000, 's'],
67
+ ];
68
+ for (const [size, unit] of units) {
69
+ if (intervalMs >= size && intervalMs % size === 0) {
70
+ return `${intervalMs / size}${unit}`;
71
+ }
72
+ }
73
+ return `${intervalMs}ms`;
74
+ }
75
+
76
+ function formatSchedule(schedule: AutomationScheduleDefinition): string {
77
+ switch (schedule.kind) {
78
+ case 'cron':
79
+ return schedule.timezone ? `${schedule.expression} [${schedule.timezone}]` : schedule.expression;
80
+ case 'every':
81
+ return formatEveryInterval(schedule.intervalMs);
82
+ case 'at':
83
+ return new Date(schedule.at).toLocaleString();
84
+ }
85
+ }
86
+
87
+ function isWatcherActive(state: string): boolean {
88
+ return state === 'running' || state === 'degraded' || state === 'starting';
89
+ }
90
+
41
91
  type AutomationRun = UiAutomationSnapshot['runs'][number];
42
92
  type AutomationJob = UiAutomationSnapshot['jobs'][number];
93
+ type WatcherEntry = UiWatchersSnapshot['watchers'][number];
94
+
95
+ type FocusSection = 'runs' | 'jobs' | 'sources';
96
+
97
+ type AutomationItem =
98
+ | { readonly kind: 'run'; readonly run: AutomationRun }
99
+ | { readonly kind: 'job'; readonly job: AutomationJob }
100
+ | { readonly kind: 'source'; readonly watcher: WatcherEntry };
101
+
102
+ type ConfirmSubject = { readonly kind: 'job' | 'watcher'; readonly id: string };
103
+
104
+ type AutomationActions = Pick<AutomationManager, 'setEnabled' | 'runNow'>;
105
+ type WatcherLifecycle = Pick<WatcherRegistry, 'startWatcher' | 'stopWatcher'>;
43
106
 
44
- export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
107
+ export interface AutomationControlPanelDeps {
108
+ /** Drives real job actions (enable/disable, run now). Absent = jobs render read-only. */
109
+ readonly automationManager?: AutomationActions;
110
+ /** Drives real watcher lifecycle actions (start/stop). Absent = sources render read-only. */
111
+ readonly watcherRegistry?: WatcherLifecycle;
112
+ }
113
+
114
+ const FOCUS_SECTIONS: readonly FocusSection[] = ['runs', 'jobs', 'sources'];
115
+
116
+ /**
117
+ * AutomationControlPanel — merged automation console (WO-111).
118
+ *
119
+ * Absorbs the former SchedulePanel (jobs, enable/disable, run-now) and
120
+ * WatchersPanel (watcher health as a "Sources" section) into one dense,
121
+ * actionable view: jobs + runs + deliveries + sources, with Tab cycling
122
+ * between the three list sections.
123
+ */
124
+ export class AutomationControlPanel extends ScrollableListPanel<AutomationItem> {
45
125
  private readonly readModel?: UiReadModel<UiAutomationSnapshot>;
126
+ private readonly watchersReadModel?: UiReadModel<UiWatchersSnapshot>;
127
+ private readonly automationManager?: AutomationActions;
128
+ private readonly watcherRegistry?: WatcherLifecycle;
46
129
  private readonly unsub: (() => void) | null;
130
+ private focusSection: FocusSection = 'runs';
131
+ private confirmAction: ConfirmState<ConfirmSubject> | null = null;
47
132
 
48
- public constructor(readModel?: UiReadModel<UiAutomationSnapshot>) {
49
- super('automation', 'Automation', 'M', 'monitoring');
133
+ public constructor(
134
+ readModel?: UiReadModel<UiAutomationSnapshot>,
135
+ watchersReadModel?: UiReadModel<UiWatchersSnapshot>,
136
+ deps: AutomationControlPanelDeps = {},
137
+ ) {
138
+ super('automation', 'Automation', '◨', 'automation-control');
50
139
  this.showSelectionGutter = true; // I5: non-color selection affordance
51
140
  this.filterEnabled = true;
52
- this.filterLabel = 'Filter runs';
141
+ this.filterLabel = 'Filter';
53
142
  this.readModel = readModel;
54
- this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
143
+ this.watchersReadModel = watchersReadModel;
144
+ this.automationManager = deps.automationManager;
145
+ this.watcherRegistry = deps.watcherRegistry;
146
+ const unsubAutomation = readModel?.subscribe(() => this.markDirty());
147
+ const unsubWatchers = watchersReadModel?.subscribe(() => this.markDirty());
148
+ this.unsub = (unsubAutomation || unsubWatchers)
149
+ ? () => { unsubAutomation?.(); unsubWatchers?.(); }
150
+ : null;
55
151
  }
56
152
 
57
153
  public override onDestroy(): void {
@@ -62,23 +158,155 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
62
158
  return C;
63
159
  }
64
160
 
161
+ // -------------------------------------------------------------------------
162
+ // Data
163
+ // -------------------------------------------------------------------------
164
+
65
165
  private getJobs(): readonly AutomationJob[] {
66
- if (!this.readModel) return [];
67
- return this.readModel.getSnapshot().jobs;
166
+ return this.readModel?.getSnapshot().jobs ?? [];
167
+ }
168
+
169
+ private getRuns(): readonly AutomationRun[] {
170
+ return this.readModel?.getSnapshot().runs ?? [];
68
171
  }
69
172
 
70
- protected getItems(): readonly AutomationRun[] {
71
- if (!this.readModel) return [];
72
- return this.readModel.getSnapshot().runs;
173
+ private getWatchers(): readonly WatcherEntry[] {
174
+ return this.watchersReadModel?.getSnapshot().watchers ?? [];
73
175
  }
74
176
 
75
- protected override filterMatches(run: AutomationRun, q: string): boolean {
76
- return run.jobId.toLowerCase().includes(q)
77
- || run.status.toLowerCase().includes(q)
78
- || run.target.kind.toLowerCase().includes(q);
177
+ protected getItems(): readonly AutomationItem[] {
178
+ if (this.focusSection === 'jobs') return this.getJobs().map((job): AutomationItem => ({ kind: 'job', job }));
179
+ if (this.focusSection === 'sources') return this.getWatchers().map((watcher): AutomationItem => ({ kind: 'source', watcher }));
180
+ return this.getRuns().map((run): AutomationItem => ({ kind: 'run', run }));
79
181
  }
80
182
 
81
- protected renderItem(run: AutomationRun, _index: number, selected: boolean, width: number): Line {
183
+ protected override filterMatches(item: AutomationItem, q: string): boolean {
184
+ if (item.kind === 'run') {
185
+ return item.run.jobId.toLowerCase().includes(q)
186
+ || item.run.status.toLowerCase().includes(q)
187
+ || item.run.target.kind.toLowerCase().includes(q);
188
+ }
189
+ if (item.kind === 'job') {
190
+ return item.job.name.toLowerCase().includes(q) || item.job.id.toLowerCase().includes(q);
191
+ }
192
+ return item.watcher.label.toLowerCase().includes(q)
193
+ || item.watcher.state.toLowerCase().includes(q)
194
+ || String(item.watcher.sourceStatus ?? '').toLowerCase().includes(q);
195
+ }
196
+
197
+ protected override getEmptyStateMessage(): string {
198
+ if (this.focusSection === 'jobs') return ' No automation jobs configured.';
199
+ if (this.focusSection === 'sources') return ' No watchers registered.';
200
+ return ' No automation activity recorded.';
201
+ }
202
+
203
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
204
+ if (this.focusSection === 'sources') {
205
+ return [{ command: '/services auth-review', summary: 'validate integration credentials before enabling remote watchers' }];
206
+ }
207
+ return [{ command: '/schedule add cron 0 * * * * repo sweep', summary: 'create a recurring automation job' }];
208
+ }
209
+
210
+ // -------------------------------------------------------------------------
211
+ // Input
212
+ // -------------------------------------------------------------------------
213
+
214
+ handleInput(key: string): boolean {
215
+ if (this.lastError !== null) this.clearError();
216
+
217
+ if (this.confirmAction) {
218
+ const result = handleConfirmInput(this.confirmAction, key);
219
+ if (result === 'confirmed') {
220
+ this._executeConfirmed(this.confirmAction.subject);
221
+ this.confirmAction = null;
222
+ this.markDirty();
223
+ return true;
224
+ }
225
+ if (result === 'cancelled') {
226
+ this.confirmAction = null;
227
+ this.markDirty();
228
+ }
229
+ return true;
230
+ }
231
+
232
+ if (!this.filterActive) {
233
+ switch (key) {
234
+ case 'tab':
235
+ this._cycleFocus();
236
+ return true;
237
+ case 'e':
238
+ this._toggleSelected();
239
+ return true;
240
+ case 'r':
241
+ this._runSelected();
242
+ return true;
243
+ case 'R':
244
+ this.markDirty();
245
+ return true;
246
+ default:
247
+ break;
248
+ }
249
+ }
250
+
251
+ return super.handleInput(key);
252
+ }
253
+
254
+ private _cycleFocus(): void {
255
+ const idx = FOCUS_SECTIONS.indexOf(this.focusSection);
256
+ this.focusSection = FOCUS_SECTIONS[(idx + 1) % FOCUS_SECTIONS.length]!;
257
+ this.selectedIndex = 0;
258
+ this.markDirty();
259
+ }
260
+
261
+ private _toggleSelected(): void {
262
+ const item = this.getSelectedItem();
263
+ if (!item) return;
264
+ if (item.kind === 'job') {
265
+ if (item.job.enabled) {
266
+ this.confirmAction = { subject: { kind: 'job', id: item.job.id }, label: item.job.name, verb: 'Disable' };
267
+ } else if (this.automationManager) {
268
+ void this.automationManager.setEnabled(item.job.id, true).catch(() => {});
269
+ }
270
+ this.markDirty();
271
+ return;
272
+ }
273
+ if (item.kind === 'source' && this.watcherRegistry) {
274
+ if (isWatcherActive(item.watcher.state)) {
275
+ this.confirmAction = { subject: { kind: 'watcher', id: item.watcher.id }, label: item.watcher.label, verb: 'Stop' };
276
+ } else {
277
+ this.watcherRegistry.startWatcher(item.watcher.id);
278
+ }
279
+ this.markDirty();
280
+ }
281
+ }
282
+
283
+ private _runSelected(): void {
284
+ const item = this.getSelectedItem();
285
+ if (item?.kind === 'job' && this.automationManager) {
286
+ void this.automationManager.runNow(item.job.id).catch(() => {});
287
+ this.markDirty();
288
+ }
289
+ }
290
+
291
+ private _executeConfirmed(subject: ConfirmSubject): void {
292
+ if (subject.kind === 'job') {
293
+ void this.automationManager?.setEnabled(subject.id, false).catch(() => {});
294
+ } else {
295
+ this.watcherRegistry?.stopWatcher(subject.id, 'user requested');
296
+ }
297
+ }
298
+
299
+ // -------------------------------------------------------------------------
300
+ // Rendering — item rows
301
+ // -------------------------------------------------------------------------
302
+
303
+ protected renderItem(item: AutomationItem, _index: number, selected: boolean, width: number): Line {
304
+ if (item.kind === 'run') return this._renderRunRow(item.run, selected, width);
305
+ if (item.kind === 'job') return this._renderJobRow(item.job, selected, width);
306
+ return this._renderSourceRow(item.watcher, selected, width);
307
+ }
308
+
309
+ private _renderRunRow(run: AutomationRun, selected: boolean, width: number): Line {
82
310
  const bg = selected ? C.selectBg : undefined;
83
311
  const jobs = this.getJobs();
84
312
  const name = jobs.find((job) => job.id === run.jobId)?.name ?? run.jobId;
@@ -91,19 +319,35 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
91
319
  ]);
92
320
  }
93
321
 
94
- protected override getEmptyStateMessage(): string {
95
- return ' No automation activity recorded.';
322
+ private _renderJobRow(job: AutomationJob, selected: boolean, width: number): Line {
323
+ const bg = selected ? C.selectBg : undefined;
324
+ const bullet = job.enabled ? '● ' : '○ ';
325
+ const bulletFg = job.enabled ? C.good : C.warn;
326
+ return buildPanelLine(width, [
327
+ [bullet, bulletFg, bg],
328
+ [truncateDisplay(job.name, 24).padEnd(24), C.value, bg],
329
+ [` ${truncateDisplay(formatSchedule(job.schedule), 18).padEnd(18)}`, C.info, bg],
330
+ [` next ${truncateDisplay(formatTime(job.nextRunAt), Math.max(0, width - 51))}`, C.dim, bg],
331
+ ]);
96
332
  }
97
333
 
98
- protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
99
- return [
100
- { command: '/schedule add cron 0 * * * * repo sweep', summary: 'create a recurring automation job' },
101
- { command: '/schedule list', summary: 'inspect jobs and run history from the shell' },
102
- ];
334
+ private _renderSourceRow(watcher: WatcherEntry, selected: boolean, width: number): Line {
335
+ const bg = selected ? C.selectBg : undefined;
336
+ return buildPanelLine(width, [
337
+ [' ', C.label, bg],
338
+ [watcher.state.padEnd(10), stateColor(watcher.state), bg],
339
+ [` ${truncateDisplay(watcher.label, 18).padEnd(18)}`, C.value, bg],
340
+ [` ${String(watcher.sourceStatus ?? 'unknown').padEnd(10)}`, sourceStatusColor(watcher.sourceStatus), bg],
341
+ [` ${truncateDisplay(formatLag(watcher.sourceLagMs), Math.max(0, width - 43))}`, C.dim, bg],
342
+ ]);
103
343
  }
104
344
 
345
+ // -------------------------------------------------------------------------
346
+ // Rendering — workspace
347
+ // -------------------------------------------------------------------------
348
+
105
349
  public render(width: number, height: number): Line[] {
106
- const intro = 'Automation jobs, active runs, deliveries, and failure posture across the shared control plane.';
350
+ const intro = 'Automation jobs, active runs, deliveries, and watcher-fed sources across the shared control plane.';
107
351
 
108
352
  if (!this.readModel) {
109
353
  const workspace = buildPanelWorkspace(width, height, {
@@ -114,7 +358,7 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
114
358
  width,
115
359
  ' Runtime store not wired.',
116
360
  'This panel needs the shared runtime store to inspect automation jobs, runs, and deliveries.',
117
- [{ command: '/schedule list', summary: 'review automation from the shell while the runtime wiring is restored' }],
361
+ [],
118
362
  C,
119
363
  ),
120
364
  }],
@@ -125,111 +369,111 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
125
369
  }
126
370
 
127
371
  const snapshot = this.readModel.getSnapshot();
128
- const jobs = [...snapshot.jobs];
129
- const runs = this.getItems();
130
372
 
131
373
  const headerLines: Line[] = [
132
374
  buildKeyValueLine(width, [
133
375
  { label: 'jobs', value: String(snapshot.totalJobs), valueColor: snapshot.totalJobs > 0 ? C.info : C.dim },
134
376
  { label: 'runs', value: String(snapshot.totalRuns), valueColor: snapshot.totalRuns > 0 ? C.value : C.dim },
135
377
  { label: 'active', value: String(snapshot.activeRunIds.length), valueColor: snapshot.activeRunIds.length > 0 ? C.warn : C.dim },
136
- { label: 'failed', value: String(snapshot.totalFailed), valueColor: snapshot.totalFailed > 0 ? C.error : C.dim },
378
+ { label: 'failed', value: String(snapshot.totalFailed), valueColor: snapshot.totalFailed > 0 ? C.bad : C.dim },
137
379
  ], C),
138
380
  buildKeyValueLine(width, [
139
- { label: 'deliveries ok', value: String(snapshot.deliveryTotals.succeeded), valueColor: snapshot.deliveryTotals.succeeded > 0 ? C.ok : C.dim },
140
- { label: 'delivery fail', value: String(snapshot.deliveryTotals.failed), valueColor: snapshot.deliveryTotals.failed > 0 ? C.error : C.dim },
381
+ { label: 'deliveries ok', value: String(snapshot.deliveryTotals.succeeded), valueColor: snapshot.deliveryTotals.succeeded > 0 ? C.good : C.dim },
382
+ { label: 'delivery fail', value: String(snapshot.deliveryTotals.failed), valueColor: snapshot.deliveryTotals.failed > 0 ? C.bad : C.dim },
141
383
  { label: 'dead letters', value: String(snapshot.deliveryTotals.deadLettered), valueColor: snapshot.deliveryTotals.deadLettered > 0 ? C.warn : C.dim },
142
384
  { label: 'sources', value: String(snapshot.sourceCount), valueColor: snapshot.sourceCount > 0 ? C.info : C.dim },
143
385
  ], C),
144
- buildGuidanceLine(width, '/schedule list', 'manage jobs and use the web or surface controls for retries, delivery, and cross-surface sessions', C),
386
+ this._buildFocusTabsLine(width),
145
387
  ];
146
388
 
147
- if (jobs.length === 0 && runs.length === 0) {
148
- return this.renderList(width, height, {
149
- title: 'Automation Control',
150
- header: headerLines,
151
- emptyMessage: ' No automation activity recorded.',
152
- });
153
- }
154
-
389
+ const items = this.getVisibleItems();
155
390
  this.clampSelection();
156
- const selectedRun = runs[this.selectedIndex];
157
- const jobName = selectedRun ? (jobs.find((job) => job.id === selectedRun.jobId)?.name ?? selectedRun.jobId) : 'n/a';
391
+ const selected = this.getSelectedItem();
158
392
 
159
393
  const footerLines: Line[] = [];
160
- if (selectedRun) {
161
- footerLines.push(
162
- buildPanelLine(width, [
163
- [' Run: ', C.label],
164
- [selectedRun.id, C.value],
165
- [' Status: ', C.label],
166
- [selectedRun.status, runStatusColor(selectedRun.status)],
167
- ]),
168
- buildPanelLine(width, [
169
- [' Job: ', C.label],
170
- [jobName, C.value],
171
- [' Agent: ', C.label],
172
- [selectedRun.agentId ?? 'n/a', C.info],
173
- ]),
174
- buildPanelLine(width, [
175
- [' Queue: ', C.label],
176
- [formatTime(selectedRun.queuedAt), C.dim],
177
- [' End: ', C.label],
178
- [formatTime(selectedRun.endedAt), C.dim],
179
- ]),
180
- buildPanelLine(width, [
181
- [' Trigger: ', C.label],
182
- [selectedRun.triggeredBy.kind, C.info],
183
- [' Target: ', C.label],
184
- [selectedRun.target.kind, C.value],
185
- ]),
186
- buildPanelLine(width, [
187
- [' Deliveries: ', C.label],
188
- [String(selectedRun.deliveryIds.length), selectedRun.deliveryIds.length > 0 ? C.info : C.dim],
189
- [' Route: ', C.label],
190
- [selectedRun.routeId ?? 'n/a', C.dim],
191
- ]),
192
- );
193
- if (selectedRun.error) {
194
- footerLines.push(buildPanelLine(width, [
195
- [' Error: ', C.label],
196
- [truncateDisplay(selectedRun.error, Math.max(0, width - 10)), C.error],
197
- ]));
198
- }
199
- } else {
200
- footerLines.push(buildPanelLine(width, [[' No run selected.', C.dim]]));
201
- }
202
-
203
- // Jobs quick view
204
- if (jobs.length > 0) {
205
- const enabledJobs = jobs.filter((job) => job.enabled).length;
206
- footerLines.push(buildSectionHeader(width, `Jobs (${enabledJobs} enabled / ${jobs.length})`, C));
207
- footerLines.push(
208
- ...jobs.slice(0, 6).map((job) => buildPanelLine(width, [
209
- [' ', C.label],
210
- [job.enabled ? 'ENABLED ' : 'PAUSED ', job.enabled ? C.ok : C.warn],
211
- [truncateDisplay(job.name, 24).padEnd(24), C.value],
212
- [` next ${truncateDisplay(formatTime(job.nextRunAt), Math.max(0, width - 43))}`, C.dim],
213
- ])),
214
- );
215
- }
216
- footerLines.push(
217
- this.filterActive
218
- ? buildKeyboardHints(width, [
219
- { keys: 'type', label: 'filter runs' },
220
- { keys: 'Enter', label: 'apply' },
221
- { keys: 'Esc', label: 'clear' },
222
- ], C)
223
- : buildKeyboardHints(width, [
224
- { keys: 'Up/Down', label: 'select run' },
225
- { keys: '/', label: 'filter' },
226
- ], C),
227
- );
394
+ if (items.length > 0 && selected) {
395
+ footerLines.push(...this._buildDetailLines(width, selected, snapshot));
396
+ if (this.confirmAction) footerLines.push(...renderConfirmLines(width, this.confirmAction));
397
+ }
228
398
 
229
399
  return this.renderList(width, height, {
230
400
  title: 'Automation Control',
231
401
  header: headerLines,
232
402
  footer: footerLines,
403
+ hints: this._buildHints(selected),
233
404
  });
234
405
  }
406
+
407
+ private _buildFocusTabsLine(width: number): Line {
408
+ const seg = (label: string, section: FocusSection): [string, string] => [
409
+ ` ${label} `,
410
+ this.focusSection === section ? C.value : C.dim,
411
+ ];
412
+ return buildPanelLine(width, [
413
+ seg('Runs', 'runs'),
414
+ seg('Jobs', 'jobs'),
415
+ seg('Sources', 'sources'),
416
+ [' (Tab to switch)', C.dim],
417
+ ]);
418
+ }
419
+
420
+ private _buildHints(selected: AutomationItem | undefined): ReadonlyArray<{ keys: string; label: string }> {
421
+ if (this.filterActive) {
422
+ return [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }];
423
+ }
424
+ const hints: Array<{ keys: string; label: string }> = [
425
+ { keys: 'Up/Down', label: `select ${this.focusSection === 'jobs' ? 'job' : this.focusSection === 'sources' ? 'source' : 'run'}` },
426
+ { keys: 'Tab', label: 'section' },
427
+ ];
428
+ if (this.focusSection === 'jobs' && selected?.kind === 'job' && this.automationManager) {
429
+ hints.push({ keys: 'e', label: selected.job.enabled ? 'disable' : 'enable' });
430
+ hints.push({ keys: 'r', label: 'run now' });
431
+ }
432
+ if (this.focusSection === 'sources' && this.watcherRegistry && selected?.kind === 'source') {
433
+ hints.push({ keys: 'e', label: isWatcherActive(selected.watcher.state) ? 'stop' : 'start' });
434
+ }
435
+ hints.push({ keys: 'R', label: 'refresh' }, { keys: '/', label: 'filter' });
436
+ return hints;
437
+ }
438
+
439
+ private _buildDetailLines(width: number, item: AutomationItem, snapshot: UiAutomationSnapshot): Line[] {
440
+ if (item.kind === 'run') {
441
+ const jobName = this.getJobs().find((job) => job.id === item.run.jobId)?.name ?? item.run.jobId;
442
+ const rows: Line[] = [
443
+ buildPanelLine(width, [[' Run: ', C.label], [item.run.id, C.value], [' Status: ', C.label], [item.run.status, runStatusColor(item.run.status)]]),
444
+ buildPanelLine(width, [[' Job: ', C.label], [jobName, C.value], [' Agent: ', C.label], [item.run.agentId ?? 'n/a', C.info]]),
445
+ buildPanelLine(width, [[' Queue: ', C.label], [formatTime(item.run.queuedAt), C.dim], [' End: ', C.label], [formatTime(item.run.endedAt), C.dim]]),
446
+ buildPanelLine(width, [[' Trigger: ', C.label], [item.run.triggeredBy.kind, C.info], [' Target: ', C.label], [item.run.target.kind, C.value]]),
447
+ buildPanelLine(width, [[' Deliveries: ', C.label], [String(item.run.deliveryIds.length), item.run.deliveryIds.length > 0 ? C.info : C.dim], [' Route: ', C.label], [item.run.routeId ?? 'n/a', C.dim]]),
448
+ ];
449
+ if (item.run.error) {
450
+ rows.push(buildPanelLine(width, [[' Error: ', C.label], [truncateDisplay(item.run.error, Math.max(0, width - 10)), C.bad]]));
451
+ }
452
+ return buildDetailBlock(width, `Run · ${item.run.id}`, rows, C);
453
+ }
454
+ if (item.kind === 'job') {
455
+ const recent = snapshot.runs.filter((run) => run.jobId === item.job.id).slice(0, 3).reverse();
456
+ const dots = recent.length > 0
457
+ ? recent.map((run) => (run.status === 'failed' ? '✗' : run.status === 'completed' ? '✓' : '●')).join(' ')
458
+ : 'none';
459
+ const rows: Line[] = [
460
+ buildPanelLine(width, [[' Job: ', C.label], [item.job.name, C.value], [' State: ', C.label], [item.job.enabled ? 'enabled' : 'paused', item.job.enabled ? C.good : C.warn]]),
461
+ buildPanelLine(width, [[' Schedule: ', C.label], [formatSchedule(item.job.schedule), C.info]]),
462
+ buildPanelLine(width, [[' Next: ', C.label], [formatTime(item.job.nextRunAt), C.dim], [' Last: ', C.label], [formatTime(item.job.lastRunAt), C.dim]]),
463
+ buildPanelLine(width, [[' Runs: ', C.label], [String(item.job.runCount), C.value], [' Recent: ', C.label], [dots, C.dim]]),
464
+ buildPanelLine(width, [[' Prompt: ', C.label], [truncateDisplay(item.job.execution.prompt ?? item.job.description ?? '', Math.max(0, width - 12)), C.dim]]),
465
+ ];
466
+ return buildDetailBlock(width, `Job · ${item.job.name}`, rows, C);
467
+ }
468
+ const w = item.watcher;
469
+ const rows: Line[] = [
470
+ buildPanelLine(width, [[' Watcher: ', C.label], [w.label, C.value], [' Kind: ', C.label], [w.kind, C.info]]),
471
+ buildPanelLine(width, [[' State: ', C.label], [w.state, stateColor(w.state)], [' Source: ', C.label], [w.source.kind, C.value]]),
472
+ buildPanelLine(width, [[' Source status: ', C.label], [w.sourceStatus ?? 'unknown', sourceStatusColor(w.sourceStatus)], [' Lag: ', C.label], [formatLag(w.sourceLagMs), w.sourceLagMs ? C.warn : C.dim]]),
473
+ buildPanelLine(width, [[' Heartbeat: ', C.label], [formatTime(w.lastHeartbeatAt), C.dim], [' Checkpoint: ', C.label], [truncateDisplay(w.lastCheckpoint ?? 'n/a', Math.max(0, width - 38)), C.dim]]),
474
+ ];
475
+ if (w.degradedReason) rows.push(buildPanelLine(width, [[' Reason: ', C.label], [truncateDisplay(w.degradedReason, Math.max(0, width - 11)), C.warn]]));
476
+ if (w.lastError) rows.push(buildPanelLine(width, [[' Error: ', C.label], [truncateDisplay(w.lastError, Math.max(0, width - 10)), C.bad]]));
477
+ return buildDetailBlock(width, `Source · ${w.label}`, rows, C);
478
+ }
235
479
  }