@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.
- package/CHANGELOG.md +124 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed-routes.ts +60 -26
- package/src/input/handler-feed.ts +17 -8
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/handler.ts +43 -6
- package/src/input/keybindings.ts +53 -8
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +117 -1
- package/src/main.ts +5 -2
- package/src/panels/agent-inspector-panel.ts +139 -45
- package/src/panels/agent-inspector-shared.ts +147 -3
- package/src/panels/approval-panel.ts +204 -102
- package/src/panels/automation-control-panel.ts +370 -103
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/agent.ts +28 -37
- package/src/panels/builtin/development.ts +40 -32
- package/src/panels/builtin/knowledge.ts +15 -6
- package/src/panels/builtin/operations.ts +178 -117
- package/src/panels/builtin/session.ts +35 -9
- package/src/panels/builtin/shared.ts +98 -6
- package/src/panels/cockpit-panel.ts +253 -75
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +159 -56
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +354 -95
- package/src/panels/cost-tracker-panel.ts +243 -57
- package/src/panels/debug-panel.ts +347 -159
- package/src/panels/diff-panel.ts +257 -82
- package/src/panels/docs-panel.ts +166 -64
- package/src/panels/eval-panel.ts +336 -109
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +238 -159
- package/src/panels/file-preview-panel.ts +141 -59
- package/src/panels/git-panel.ts +360 -191
- package/src/panels/hooks-panel.ts +181 -19
- package/src/panels/incident-review-panel.ts +252 -43
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +310 -103
- package/src/panels/knowledge-graph-panel.ts +480 -58
- package/src/panels/local-auth-panel.ts +251 -33
- package/src/panels/marketplace-panel.ts +219 -33
- package/src/panels/memory-panel.ts +83 -81
- package/src/panels/ops-control-panel.ts +211 -32
- package/src/panels/ops-strategy-panel.ts +131 -45
- package/src/panels/orchestration-panel.ts +259 -67
- package/src/panels/panel-list-panel.ts +184 -136
- package/src/panels/panel-manager.ts +120 -13
- package/src/panels/plan-dashboard-panel.ts +507 -74
- package/src/panels/plugins-panel.ts +227 -34
- package/src/panels/policy-panel.ts +264 -55
- package/src/panels/polish-core.ts +162 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +67 -149
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +84 -113
- package/src/panels/provider-health-panel.ts +438 -480
- package/src/panels/provider-health-routes.ts +203 -0
- package/src/panels/provider-health-tracker.ts +194 -6
- package/src/panels/provider-health-views.ts +560 -0
- package/src/panels/qr-panel.ts +136 -38
- package/src/panels/remote-panel.ts +123 -38
- package/src/panels/routes-panel.ts +87 -24
- package/src/panels/sandbox-panel.ts +232 -65
- package/src/panels/scrollable-list-panel.ts +143 -145
- package/src/panels/security-panel.ts +204 -52
- package/src/panels/services-panel.ts +174 -76
- package/src/panels/session-browser-panel.ts +101 -6
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +346 -68
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +105 -34
- package/src/panels/symbol-outline-panel.ts +287 -154
- package/src/panels/system-messages-panel.ts +172 -38
- package/src/panels/tasks-panel.ts +348 -134
- package/src/panels/thinking-panel.ts +66 -32
- package/src/panels/token-budget-panel.ts +233 -46
- package/src/panels/tool-inspector-panel.ts +170 -54
- package/src/panels/types.ts +65 -4
- package/src/panels/work-plan-panel.ts +393 -39
- package/src/panels/worktree-panel.ts +239 -61
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +146 -150
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/conversation-overlays.ts +25 -11
- package/src/renderer/footer-tips.ts +41 -0
- package/src/renderer/fullscreen-primitives.ts +22 -16
- package/src/renderer/help-overlay.ts +91 -14
- package/src/renderer/hint-grammar.ts +52 -0
- package/src/renderer/layout.ts +7 -7
- package/src/renderer/modal-factory.ts +23 -15
- package/src/renderer/model-picker-overlay.ts +30 -11
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/overlay-box.ts +16 -10
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/process-indicator.ts +3 -1
- package/src/renderer/progress.ts +8 -6
- package/src/renderer/search-overlay.ts +27 -6
- package/src/renderer/session-picker-modal.ts +6 -4
- package/src/renderer/settings-modal.ts +70 -10
- package/src/renderer/shell-surface.ts +41 -15
- package/src/renderer/status-glyphs.ts +11 -9
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/theme.ts +60 -3
- package/src/renderer/ui-factory.ts +45 -36
- package/src/renderer/ui-primitives.ts +23 -2
- package/src/runtime/bootstrap-shell.ts +35 -18
- package/src/runtime/diagnostics/panels/index.ts +0 -3
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/utils/splash-lines.ts +44 -3
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +13 -0
- package/src/panels/agent-logs-panel.ts +0 -666
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -214
- package/src/panels/forensics-panel.ts +0 -364
- package/src/panels/panel-picker.ts +0 -106
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -218
- package/src/panels/provider-stats-panel.ts +0 -366
- package/src/panels/schedule-panel.ts +0 -342
- package/src/panels/watchers-panel.ts +0 -193
- package/src/renderer/panel-picker-overlay.ts +0 -202
- package/src/renderer/panel-tab-bar.ts +0 -69
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
34
|
-
if (status === 'failed' || status === 'dead_lettered') return C.
|
|
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
|
-
|
|
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(
|
|
47
|
-
|
|
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.
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
[
|
|
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.
|
|
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.
|
|
130
|
-
{ label: 'delivery fail', value: String(snapshot.deliveryTotals.failed), valueColor: snapshot.deliveryTotals.failed > 0 ? C.
|
|
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
|
-
|
|
386
|
+
this._buildFocusTabsLine(width),
|
|
135
387
|
];
|
|
136
388
|
|
|
137
|
-
|
|
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
|
|
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 (
|
|
151
|
-
footerLines.push(
|
|
152
|
-
|
|
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
|
}
|
package/src/panels/base-panel.ts
CHANGED
|
@@ -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}
|
|
85
|
+
fitDisplay(` ✕ ${this.lastError}`, width),
|
|
83
86
|
width,
|
|
84
|
-
{ fg:
|
|
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
|
|