@pellux/goodvibes-tui 0.27.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,11 +1,19 @@
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
18
  buildPanelLine,
11
19
  buildPanelWorkspace,
@@ -13,16 +21,9 @@ import {
13
21
  type PanelPalette,
14
22
  } from './polish.ts';
15
23
 
16
- const C = {
17
- ...DEFAULT_PANEL_PALETTE,
18
- header: '#94a3b8',
19
- headerBg: '#1e293b',
20
- ok: '#22c55e',
21
- warn: '#eab308',
22
- error: '#ef4444',
23
- info: '#38bdf8',
24
- selectBg: '#0f172a',
25
- } 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;
26
27
 
27
28
  function formatTime(value?: number): string {
28
29
  if (!value) return 'n/a';
@@ -30,24 +31,123 @@ function formatTime(value?: number): string {
30
31
  }
31
32
 
32
33
  function runStatusColor(status: string): string {
33
- if (status === 'completed') return C.ok;
34
- 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;
35
36
  if (status === 'cancelled') return C.warn;
36
37
  return C.info;
37
38
  }
38
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
+
39
91
  type AutomationRun = UiAutomationSnapshot['runs'][number];
40
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 };
41
101
 
42
- export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
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'>;
106
+
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> {
43
125
  private readonly readModel?: UiReadModel<UiAutomationSnapshot>;
126
+ private readonly watchersReadModel?: UiReadModel<UiWatchersSnapshot>;
127
+ private readonly automationManager?: AutomationActions;
128
+ private readonly watcherRegistry?: WatcherLifecycle;
44
129
  private readonly unsub: (() => void) | null;
130
+ private focusSection: FocusSection = 'runs';
131
+ private confirmAction: ConfirmState<ConfirmSubject> | null = null;
45
132
 
46
- public constructor(readModel?: UiReadModel<UiAutomationSnapshot>) {
47
- 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');
48
139
  this.showSelectionGutter = true; // I5: non-color selection affordance
140
+ this.filterEnabled = true;
141
+ this.filterLabel = 'Filter';
49
142
  this.readModel = readModel;
50
- 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;
51
151
  }
52
152
 
53
153
  public override onDestroy(): void {
@@ -58,17 +158,155 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
58
158
  return C;
59
159
  }
60
160
 
161
+ // -------------------------------------------------------------------------
162
+ // Data
163
+ // -------------------------------------------------------------------------
164
+
61
165
  private getJobs(): readonly AutomationJob[] {
62
- if (!this.readModel) return [];
63
- return this.readModel.getSnapshot().jobs;
166
+ return this.readModel?.getSnapshot().jobs ?? [];
167
+ }
168
+
169
+ private getRuns(): readonly AutomationRun[] {
170
+ return this.readModel?.getSnapshot().runs ?? [];
171
+ }
172
+
173
+ private getWatchers(): readonly WatcherEntry[] {
174
+ return this.watchersReadModel?.getSnapshot().watchers ?? [];
175
+ }
176
+
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 }));
181
+ }
182
+
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);
64
252
  }
65
253
 
66
- protected getItems(): readonly AutomationRun[] {
67
- if (!this.readModel) return [];
68
- return this.readModel.getSnapshot().runs;
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();
69
259
  }
70
260
 
71
- protected renderItem(run: AutomationRun, _index: number, selected: boolean, width: number): Line {
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 {
72
310
  const bg = selected ? C.selectBg : undefined;
73
311
  const jobs = this.getJobs();
74
312
  const name = jobs.find((job) => job.id === run.jobId)?.name ?? run.jobId;
@@ -81,19 +319,35 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
81
319
  ]);
82
320
  }
83
321
 
84
- protected override getEmptyStateMessage(): string {
85
- 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
+ ]);
86
332
  }
87
333
 
88
- protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
89
- return [
90
- { command: '/schedule add cron 0 * * * * repo sweep', summary: 'create a recurring automation job' },
91
- { command: '/schedule list', summary: 'inspect jobs and run history from the shell' },
92
- ];
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
+ ]);
93
343
  }
94
344
 
345
+ // -------------------------------------------------------------------------
346
+ // Rendering — workspace
347
+ // -------------------------------------------------------------------------
348
+
95
349
  public render(width: number, height: number): Line[] {
96
- 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.';
97
351
 
98
352
  if (!this.readModel) {
99
353
  const workspace = buildPanelWorkspace(width, height, {
@@ -104,7 +358,7 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
104
358
  width,
105
359
  ' Runtime store not wired.',
106
360
  'This panel needs the shared runtime store to inspect automation jobs, runs, and deliveries.',
107
- [{ command: '/schedule list', summary: 'review automation from the shell while the runtime wiring is restored' }],
361
+ [],
108
362
  C,
109
363
  ),
110
364
  }],
@@ -115,98 +369,111 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
115
369
  }
116
370
 
117
371
  const snapshot = this.readModel.getSnapshot();
118
- const jobs = [...snapshot.jobs];
119
- const runs = this.getItems();
120
372
 
121
373
  const headerLines: Line[] = [
122
374
  buildKeyValueLine(width, [
123
375
  { label: 'jobs', value: String(snapshot.totalJobs), valueColor: snapshot.totalJobs > 0 ? C.info : C.dim },
124
376
  { label: 'runs', value: String(snapshot.totalRuns), valueColor: snapshot.totalRuns > 0 ? C.value : C.dim },
125
377
  { label: 'active', value: String(snapshot.activeRunIds.length), valueColor: snapshot.activeRunIds.length > 0 ? C.warn : C.dim },
126
- { 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 },
127
379
  ], C),
128
380
  buildKeyValueLine(width, [
129
- { label: 'deliveries ok', value: String(snapshot.deliveryTotals.succeeded), valueColor: snapshot.deliveryTotals.succeeded > 0 ? C.ok : C.dim },
130
- { 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 },
131
383
  { label: 'dead letters', value: String(snapshot.deliveryTotals.deadLettered), valueColor: snapshot.deliveryTotals.deadLettered > 0 ? C.warn : C.dim },
132
384
  { label: 'sources', value: String(snapshot.sourceCount), valueColor: snapshot.sourceCount > 0 ? C.info : C.dim },
133
385
  ], C),
134
- 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),
135
387
  ];
136
388
 
137
- if (jobs.length === 0 && runs.length === 0) {
138
- return this.renderList(width, height, {
139
- title: 'Automation Control',
140
- header: headerLines,
141
- emptyMessage: ' No automation activity recorded.',
142
- });
143
- }
144
-
389
+ const items = this.getVisibleItems();
145
390
  this.clampSelection();
146
- const selectedRun = runs[this.selectedIndex];
147
- const jobName = selectedRun ? (jobs.find((job) => job.id === selectedRun.jobId)?.name ?? selectedRun.jobId) : 'n/a';
391
+ const selected = this.getSelectedItem();
148
392
 
149
393
  const footerLines: Line[] = [];
150
- if (selectedRun) {
151
- footerLines.push(
152
- buildPanelLine(width, [
153
- [' Run: ', C.label],
154
- [selectedRun.id, C.value],
155
- [' Status: ', C.label],
156
- [selectedRun.status, runStatusColor(selectedRun.status)],
157
- ]),
158
- buildPanelLine(width, [
159
- [' Job: ', C.label],
160
- [jobName, C.value],
161
- [' Agent: ', C.label],
162
- [selectedRun.agentId ?? 'n/a', C.info],
163
- ]),
164
- buildPanelLine(width, [
165
- [' Queue: ', C.label],
166
- [formatTime(selectedRun.queuedAt), C.dim],
167
- [' End: ', C.label],
168
- [formatTime(selectedRun.endedAt), C.dim],
169
- ]),
170
- buildPanelLine(width, [
171
- [' Trigger: ', C.label],
172
- [selectedRun.triggeredBy.kind, C.info],
173
- [' Target: ', C.label],
174
- [selectedRun.target.kind, C.value],
175
- ]),
176
- buildPanelLine(width, [
177
- [' Deliveries: ', C.label],
178
- [String(selectedRun.deliveryIds.length), selectedRun.deliveryIds.length > 0 ? C.info : C.dim],
179
- [' Route: ', C.label],
180
- [selectedRun.routeId ?? 'n/a', C.dim],
181
- ]),
182
- );
183
- if (selectedRun.error) {
184
- footerLines.push(buildPanelLine(width, [
185
- [' Error: ', C.label],
186
- [truncateDisplay(selectedRun.error, Math.max(0, width - 10)), C.error],
187
- ]));
188
- }
189
- } else {
190
- footerLines.push(buildPanelLine(width, [[' No run selected.', C.dim]]));
394
+ if (items.length > 0 && selected) {
395
+ footerLines.push(...this._buildDetailLines(width, selected, snapshot));
396
+ if (this.confirmAction) footerLines.push(...renderConfirmLines(width, this.confirmAction));
191
397
  }
192
398
 
193
- // Jobs quick view
194
- if (jobs.length > 0) {
195
- footerLines.push(
196
- ...jobs.slice(0, 6).map((job) => buildPanelLine(width, [
197
- [' ', C.label],
198
- [job.enabled ? 'ENABLED ' : 'PAUSED ', job.enabled ? C.ok : C.warn],
199
- [truncateDisplay(job.name, 24).padEnd(24), C.value],
200
- [` next ${truncateDisplay(formatTime(job.nextRunAt), Math.max(0, width - 43))}`, C.dim],
201
- ])),
202
- );
203
- }
204
- footerLines.push(buildPanelLine(width, [[' Up/Down move through runs', C.dim]]));
205
-
206
399
  return this.renderList(width, height, {
207
400
  title: 'Automation Control',
208
401
  header: headerLines,
209
402
  footer: footerLines,
403
+ hints: this._buildHints(selected),
210
404
  });
211
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
+ }
212
479
  }
@@ -6,6 +6,9 @@ import { UIFactory } from '../renderer/ui-factory.ts';
6
6
  import { SPINNER_FRAMES } from '../renderer/progress.ts';
7
7
  import { fitDisplay } from '../utils/terminal-width.ts';
8
8
 
9
+ /** Canonical error-surface foreground (bad/red), kept out of the render body. */
10
+ const ERROR_FG = '#ef4444';
11
+
9
12
  export abstract class BasePanel implements Panel {
10
13
  public needsRender = true;
11
14
  public isTransient = false;
@@ -79,9 +82,9 @@ export abstract class BasePanel implements Panel {
79
82
  protected renderErrorLine(width: number): Line | null {
80
83
  if (!this.lastError) return null;
81
84
  return UIFactory.stringToLine(
82
- ` ✕ ${this.lastError}`.padEnd(width).slice(0, width),
85
+ fitDisplay(` ✕ ${this.lastError}`, width),
83
86
  width,
84
- { fg: '#ef4444', bold: true },
87
+ { fg: ERROR_FG, bold: true },
85
88
  );
86
89
  }
87
90
 
@@ -189,6 +192,27 @@ export abstract class BasePanel implements Panel {
189
192
 
190
193
  abstract render(width: number, height: number): Line[];
191
194
 
195
+ /**
196
+ * Default mouse-wheel handling: translate a wheel delta into the panel's
197
+ * existing up/down keyboard navigation, so every panel scrolls with the wheel
198
+ * without bespoke code. Panels with richer scroll state (e.g.
199
+ * `ScrollableListPanel`) override this. No-op for panels that don't implement
200
+ * `handleInput`. Returns true if any step was consumed.
201
+ */
202
+ public handleScroll(deltaRows: number): boolean {
203
+ const self = this as unknown as { handleInput?: (key: string) => boolean };
204
+ if (typeof self.handleInput !== 'function') return false;
205
+ const rows = Math.trunc(deltaRows);
206
+ if (rows === 0) return false;
207
+ const key = rows > 0 ? 'down' : 'up';
208
+ const steps = Math.min(Math.abs(rows), 10); // clamp runaway wheel deltas
209
+ let consumed = false;
210
+ for (let i = 0; i < steps; i++) {
211
+ if (self.handleInput(key)) consumed = true;
212
+ }
213
+ return consumed;
214
+ }
215
+
192
216
  /** R2: Mark this panel dirty — it will be re-rendered on the next compositor frame. */
193
217
  public invalidate(): void { this.needsRender = true; }
194
218