@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,366 +0,0 @@
1
- import { BasePanel } from './base-panel.ts';
2
- import type { Line } from '../types/grid.ts';
3
- import type { ProviderEvent, TurnEvent } from '@/runtime/index.ts';
4
- import type { UiEventFeed } from '../runtime/ui-events.ts';
5
- import type { UiProvidersSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
6
- import {
7
- buildEmptyState,
8
- buildKeyValueLine,
9
- buildStyledPanelLine,
10
- buildPanelWorkspace,
11
- DEFAULT_PANEL_PALETTE,
12
- type PanelWorkspaceSection,
13
- } from './polish.ts';
14
- import { truncateDisplay } from '../utils/terminal-width.ts';
15
-
16
- // ---------------------------------------------------------------------------
17
- // Constants
18
- // ---------------------------------------------------------------------------
19
-
20
- const SPARKLINE_CHARS = '._-:=+*#';
21
- const LATENCY_RING_SIZE = 20;
22
-
23
- /** Latency thresholds in ms for color-coding. */
24
- const LATENCY_GREEN = 500;
25
- const LATENCY_YELLOW = 2000;
26
-
27
- // ---------------------------------------------------------------------------
28
- // Types
29
- // ---------------------------------------------------------------------------
30
-
31
- interface ProviderRecord {
32
- /** Provider name (e.g. 'anthropic', 'openai'). */
33
- name: string;
34
- /** Currently active model ID for this provider (last seen). */
35
- lastModelId: string;
36
- /** Ring buffer of per-request latencies in ms (most-recent last). */
37
- latencies: number[];
38
- /** Total request count. */
39
- requests: number;
40
- /** Error count. */
41
- errors: number;
42
- /** Total input + output tokens summed across all requests. */
43
- totalTokens: number;
44
- }
45
-
46
- // ---------------------------------------------------------------------------
47
- // ProviderStatsPanel
48
- // ---------------------------------------------------------------------------
49
-
50
- export class ProviderStatsPanel extends BasePanel {
51
- /** Per-provider metrics keyed by provider name. */
52
- private records: Map<string, ProviderRecord> = new Map();
53
-
54
- /** Timestamp (ms) recorded at turn:start — used to compute turn latency. */
55
- private _turnStartMs: number | null = null;
56
-
57
- /** Timestamp for the current streaming LLM call start. */
58
- private _streamStartMs: number | null = null;
59
-
60
- /** Unsubscribe functions for event listeners. */
61
- private _unsubs: Array<() => void> = [];
62
-
63
- constructor(
64
- private readonly turnEvents: UiEventFeed<TurnEvent>,
65
- private readonly providerEvents: UiEventFeed<ProviderEvent>,
66
- private readonly requestRender: () => void = () => {},
67
- private readonly providers: UiReadModel<UiProvidersSnapshot>,
68
- ) {
69
- super('providers', 'Providers', 'R', 'monitoring');
70
- this._subscribe();
71
- }
72
-
73
- // -------------------------------------------------------------------------
74
- // Event subscription
75
- // -------------------------------------------------------------------------
76
-
77
- private _subscribe(): void {
78
- // Record when a turn starts so we can compute latency later
79
- this._unsubs.push(
80
- this.turnEvents.on('TURN_SUBMITTED', () => {
81
- this._turnStartMs = Date.now();
82
- }),
83
- );
84
-
85
- // Per-streaming-call timing (each iteration of the agentic loop)
86
- this._unsubs.push(
87
- this.turnEvents.on('STREAM_START', () => {
88
- this._streamStartMs = Date.now();
89
- }),
90
- );
91
-
92
- // After each LLM response (streamed or not), record metrics for the
93
- // current provider call inside the turn loop.
94
- this._unsubs.push(
95
- this.turnEvents.on('LLM_RESPONSE_RECEIVED', (env) => {
96
- const now = Date.now();
97
- const latencyMs = this._streamStartMs !== null
98
- ? now - this._streamStartMs
99
- : this._turnStartMs !== null
100
- ? now - this._turnStartMs
101
- : 0;
102
- // Reset stream start — ready for next iteration in the agentic loop
103
- this._streamStartMs = null;
104
- this._recordRequest(
105
- env.provider,
106
- env.model,
107
- latencyMs,
108
- false,
109
- env.inputTokens
110
- + env.outputTokens
111
- + (env.cacheReadTokens ?? 0)
112
- + (env.cacheWriteTokens ?? 0),
113
- );
114
-
115
- this.markDirty();
116
- this.requestRender();
117
- }),
118
- );
119
-
120
- // On error, record a failed request
121
- this._unsubs.push(
122
- this.turnEvents.on('TURN_ERROR', () => {
123
- this._turnStartMs = null;
124
- this._streamStartMs = null;
125
- this._recordRequest('unknown', 'unknown', 0, true, 0);
126
-
127
- this.markDirty();
128
- this.requestRender();
129
- }),
130
- );
131
-
132
- // Re-render when providers change (new custom providers loaded)
133
- this._unsubs.push(
134
- this.providerEvents.on('PROVIDERS_CHANGED', () => {
135
- this.markDirty();
136
- this.requestRender();
137
- }),
138
- );
139
- }
140
-
141
- // -------------------------------------------------------------------------
142
- // Metric recording
143
- // -------------------------------------------------------------------------
144
-
145
- private _recordRequest(
146
- providerName: string,
147
- modelId: string,
148
- latencyMs: number,
149
- isError: boolean,
150
- tokens: number,
151
- ): void {
152
- let rec = this.records.get(providerName);
153
- if (!rec) {
154
- rec = {
155
- name: providerName,
156
- lastModelId: modelId,
157
- latencies: [],
158
- requests: 0,
159
- errors: 0,
160
- totalTokens: 0,
161
- };
162
- this.records.set(providerName, rec);
163
- }
164
-
165
- rec.lastModelId = modelId;
166
- rec.requests++;
167
- if (isError) rec.errors++;
168
- rec.totalTokens += tokens;
169
-
170
- if (latencyMs > 0) {
171
- rec.latencies.push(latencyMs);
172
- // Keep only the most recent LATENCY_RING_SIZE samples
173
- if (rec.latencies.length > LATENCY_RING_SIZE) {
174
- rec.latencies.shift();
175
- }
176
- }
177
- }
178
-
179
- // -------------------------------------------------------------------------
180
- // Lifecycle
181
- // -------------------------------------------------------------------------
182
-
183
- override onDestroy(): void {
184
- for (const unsub of this._unsubs) unsub();
185
- this._unsubs = [];
186
- }
187
-
188
- // -------------------------------------------------------------------------
189
- // Rendering
190
- // -------------------------------------------------------------------------
191
-
192
- override render(width: number, height: number): Line[] {
193
- const knownProviders = [...this.providers.getSnapshot().providerIds];
194
-
195
- if (knownProviders.length === 0) {
196
- return buildPanelWorkspace(width, height, {
197
- title: ' Provider Stats',
198
- intro: 'Per-provider request performance, latency distribution, error pressure, and session totals.',
199
- sections: [
200
- {
201
- lines: buildEmptyState(
202
- width,
203
- ' No providers registered',
204
- 'Load or configure a provider to begin collecting per-provider latency and error metrics.',
205
- [],
206
- DEFAULT_PANEL_PALETTE,
207
- ),
208
- },
209
- ],
210
- palette: DEFAULT_PANEL_PALETTE,
211
- });
212
- }
213
-
214
- const totalReq = [...this.records.values()].reduce((sum, rec) => sum + rec.requests, 0);
215
- const totalErr = [...this.records.values()].reduce((sum, rec) => sum + rec.errors, 0);
216
- const totalTok = [...this.records.values()].reduce((sum, rec) => sum + rec.totalTokens, 0);
217
- const allLatencies = [...this.records.values()].flatMap((rec) => rec.latencies);
218
- const providerSections: PanelWorkspaceSection[] = [
219
- {
220
- title: 'Session',
221
- lines: [
222
- buildKeyValueLine(width, [
223
- { label: 'Providers', value: String(knownProviders.length) },
224
- { label: 'Requests', value: String(totalReq), valueColor: DEFAULT_PANEL_PALETTE.info },
225
- { label: 'Errors', value: String(totalErr), valueColor: totalErr > 0 ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.good },
226
- { label: 'Tokens', value: String(totalTok), valueColor: DEFAULT_PANEL_PALETTE.value },
227
- ], DEFAULT_PANEL_PALETTE),
228
- buildKeyValueLine(width, [
229
- { label: 'Avg Latency', value: this._fmtMs(this._avg(allLatencies)), valueColor: this._latencyColor(this._avg(allLatencies)) },
230
- { label: 'P95', value: this._fmtMs(this._p95(allLatencies)), valueColor: DEFAULT_PANEL_PALETTE.warn },
231
- ], DEFAULT_PANEL_PALETTE),
232
- ],
233
- },
234
- ];
235
-
236
- for (const provName of knownProviders) {
237
- const rec = this.records.get(provName);
238
- providerSections.push({
239
- title: provName,
240
- lines: this._buildProviderRows(provName, rec, width),
241
- });
242
- }
243
-
244
- return buildPanelWorkspace(width, height, {
245
- title: ' Provider Stats',
246
- intro: 'Per-provider request performance, latency distribution, error pressure, and session totals.',
247
- sections: providerSections,
248
- palette: DEFAULT_PANEL_PALETTE,
249
- });
250
- }
251
-
252
- // -------------------------------------------------------------------------
253
- // Line builders
254
- // -------------------------------------------------------------------------
255
-
256
- private _buildProviderRows(
257
- provName: string,
258
- rec: ProviderRecord | undefined,
259
- width: number,
260
- ): Line[] {
261
- const rows: Line[] = [];
262
-
263
- // Determine health
264
- const hasErrors = rec !== undefined && rec.errors > 0;
265
- const dotColor = hasErrors ? '#ef4444' : '#22c55e';
266
-
267
- // Model ID (truncated)
268
- const modelId = rec?.lastModelId ?? 'n/a';
269
- const modelDisplay = truncateDisplay(modelId, 30);
270
-
271
- // Header row: * provider model
272
- // Build as segments to avoid multi-byte char indexing issues
273
- const headerLine = buildStyledPanelLine(width, [
274
- { text: ' ', fg: '#94a3b8' },
275
- { text: '●', fg: dotColor },
276
- { text: ' ', fg: '#94a3b8' },
277
- { text: `${truncateDisplay(provName, 14).padEnd(14)} `, fg: '#e2e8f0', bold: true },
278
- { text: modelDisplay, fg: '#cbd5e1' },
279
- ]);
280
-
281
- rows.push(headerLine);
282
-
283
- if (rec === undefined || rec.requests === 0) {
284
- rows.push(buildStyledPanelLine(width, [
285
- { text: ' No requests yet.', fg: '#6b7280' },
286
- ]));
287
- } else {
288
- const avgLatency = this._avg(rec.latencies);
289
- const p95Latency = this._p95(rec.latencies);
290
- const errRate = rec.requests > 0 ? (rec.errors / rec.requests) * 100 : 0;
291
- const sparkline = this._sparkline(rec.latencies);
292
-
293
- const latFg = avgLatency < LATENCY_GREEN
294
- ? '#22c55e'
295
- : avgLatency < LATENCY_YELLOW
296
- ? '#eab308'
297
- : '#ef4444';
298
-
299
- const segments = [
300
- { text: ' avg ', fg: '#6b7280' },
301
- { text: this._fmtMs(avgLatency).padStart(6), fg: latFg, bold: true },
302
- { text: ' p95 ', fg: '#6b7280' },
303
- { text: this._fmtMs(p95Latency).padStart(6), fg: '#a78bfa' },
304
- { text: ' ', fg: '#374151' },
305
- { text: sparkline, fg: latFg },
306
- { text: ' err ', fg: '#6b7280' },
307
- { text: `${errRate.toFixed(0).padStart(3)}%`, fg: errRate > 0 ? '#ef4444' : '#22c55e' },
308
- { text: ` ${rec.requests.toString().padStart(4)}r`, fg: '#94a3b8' },
309
- ] as const;
310
- const tokenSegment = rec.totalTokens > 0
311
- ? [{ text: ` ${rec.totalTokens.toString().padStart(6)}tok`, fg: '#64748b' }]
312
- : [];
313
- rows.push(buildStyledPanelLine(width, [...segments, ...tokenSegment]));
314
- }
315
-
316
- return rows;
317
- }
318
-
319
- // -------------------------------------------------------------------------
320
- // Utilities
321
- // -------------------------------------------------------------------------
322
-
323
- private _avg(arr: number[]): number {
324
- if (arr.length === 0) return 0;
325
- return arr.reduce((s, v) => s + v, 0) / arr.length;
326
- }
327
-
328
- private _p95(arr: number[]): number {
329
- if (arr.length === 0) return 0;
330
- const sorted = [...arr].sort((a, b) => a - b);
331
- const idx = Math.floor(sorted.length * 0.95);
332
- return sorted[Math.min(idx, sorted.length - 1)] ?? 0;
333
- }
334
-
335
- private _sparkline(latencies: number[]): string {
336
- if (latencies.length === 0) return ' '.repeat(LATENCY_RING_SIZE);
337
- const vals = latencies.slice(-LATENCY_RING_SIZE);
338
- const minV = Math.min(...vals);
339
- const maxV = Math.max(...vals);
340
- const range = maxV - minV || 1;
341
- const spark: string[] = vals.map((v) => {
342
- const idx = Math.min(
343
- SPARKLINE_CHARS.length - 1,
344
- Math.floor(((v - minV) / range) * (SPARKLINE_CHARS.length - 1)),
345
- );
346
- return SPARKLINE_CHARS[idx] ?? '.';
347
- });
348
- // Pad left to always be LATENCY_RING_SIZE wide
349
- while (spark.length < LATENCY_RING_SIZE) spark.unshift(' ');
350
- return spark.join('');
351
- }
352
-
353
- private _fmtMs(ms: number): string {
354
- if (ms <= 0) return 'n/a';
355
- if (ms >= 10000) return `${(ms / 1000).toFixed(1)}s`;
356
- if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
357
- return `${Math.round(ms)}ms`;
358
- }
359
-
360
- private _latencyColor(ms: number): string {
361
- if (ms <= 0) return DEFAULT_PANEL_PALETTE.dim;
362
- if (ms < LATENCY_GREEN) return DEFAULT_PANEL_PALETTE.good;
363
- if (ms < LATENCY_YELLOW) return DEFAULT_PANEL_PALETTE.warn;
364
- return DEFAULT_PANEL_PALETTE.bad;
365
- }
366
- }
@@ -1,342 +0,0 @@
1
- import { BasePanel } from './base-panel.ts';
2
- import { type Line } from '../types/grid.ts';
3
- import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automation';
4
- import type { AutomationJob } from '@pellux/goodvibes-sdk/platform/automation';
5
- import type { AutomationRun } from '@pellux/goodvibes-sdk/platform/automation';
6
- import type { AutomationScheduleDefinition } from '@pellux/goodvibes-sdk/platform/automation';
7
- import {
8
- buildEmptyState,
9
- buildPanelLine,
10
- buildPanelWorkspace,
11
- extendPalette,
12
- resolveScrollablePanelSection,
13
- DEFAULT_PANEL_PALETTE,
14
- type PanelWorkspaceSection,
15
- } from './polish.ts';
16
-
17
- // ---------------------------------------------------------------------------
18
- // Colors
19
- // ---------------------------------------------------------------------------
20
-
21
- const C = extendPalette(DEFAULT_PANEL_PALETTE, {
22
- enabled: '#5fd700',
23
- disabled: '#6c6c6c',
24
- selectedFg: '#ffffff',
25
- id: '238',
26
- cron: '#af87ff',
27
- prompt: '250',
28
- nextRun: '#87afff',
29
- lastRun: '244',
30
- runCount: '#ffaf00',
31
- statusRunning: '#5fd700',
32
- statusFailed: '#ff5f5f',
33
- });
34
-
35
- // ---------------------------------------------------------------------------
36
- // View items
37
- // ---------------------------------------------------------------------------
38
-
39
- type ViewItem =
40
- | { kind: 'header' }
41
- | { kind: 'task'; task: AutomationJob; history: AutomationRun[] }
42
- | { kind: 'empty' };
43
-
44
- type ScheduleAutomationManager = Pick<
45
- AutomationManager,
46
- 'start' | 'listJobs' | 'listRuns' | 'setEnabled' | 'runNow'
47
- >;
48
-
49
- function formatSchedule(schedule: AutomationScheduleDefinition): string {
50
- switch (schedule.kind) {
51
- case 'cron':
52
- return schedule.timezone ? `${schedule.expression} [${schedule.timezone}]` : schedule.expression;
53
- case 'every':
54
- return formatEveryInterval(schedule.intervalMs);
55
- case 'at':
56
- return new Date(schedule.at).toLocaleString();
57
- }
58
- }
59
-
60
- function formatEveryInterval(intervalMs: number): string {
61
- const units: ReadonlyArray<readonly [number, string]> = [
62
- [86_400_000, 'd'],
63
- [3_600_000, 'h'],
64
- [60_000, 'm'],
65
- [1_000, 's'],
66
- ];
67
- for (const [size, unit] of units) {
68
- if (intervalMs >= size && intervalMs % size === 0) {
69
- return `${intervalMs / size}${unit}`;
70
- }
71
- }
72
- return `${intervalMs}ms`;
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // SchedulePanel
77
- // ---------------------------------------------------------------------------
78
-
79
- /**
80
- * SchedulePanel — displays all scheduled tasks with next run time,
81
- * enable/disable toggle, and run history.
82
- */
83
- export class SchedulePanel extends BasePanel {
84
- private items: ViewItem[] = [];
85
- private selectedIndex = 0;
86
- private scrollOffset = 0;
87
- private refreshTimerId: ReturnType<typeof setInterval> | null = null;
88
- private readonly automationManager: ScheduleAutomationManager;
89
-
90
- constructor(automationManager: ScheduleAutomationManager) {
91
- super('schedule', 'Schedule', 'Z', 'agent');
92
- this.automationManager = automationManager;
93
- }
94
-
95
- // -------------------------------------------------------------------------
96
- // Lifecycle
97
- // -------------------------------------------------------------------------
98
-
99
- override onActivate(): void {
100
- super.onActivate();
101
- void this.automationManager.start().then(() => {
102
- this.rebuild();
103
- this.markDirty();
104
- });
105
- this.rebuild();
106
- this.refreshTimerId = this.registerTimer(setInterval(() => {
107
- this.rebuild();
108
- this.markDirty();
109
- }, 5_000));
110
- }
111
-
112
- override onDeactivate(): void {
113
- if (this.refreshTimerId !== null) {
114
- this.clearTimer(this.refreshTimerId);
115
- this.refreshTimerId = null;
116
- }
117
- }
118
-
119
- override onDestroy(): void {
120
- this.onDeactivate();
121
- super.onDestroy();
122
- }
123
-
124
- // -------------------------------------------------------------------------
125
- // Data
126
- // -------------------------------------------------------------------------
127
-
128
- private rebuild(): void {
129
- const manager = this.automationManager;
130
- const tasks = manager.listJobs();
131
-
132
- const items: ViewItem[] = [{ kind: 'header' }];
133
- if (tasks.length === 0) {
134
- items.push({ kind: 'empty' });
135
- } else {
136
- for (const task of tasks) {
137
- items.push({
138
- kind: 'task',
139
- task,
140
- history: manager.listRuns(task.id),
141
- });
142
- }
143
- }
144
- this.items = items;
145
-
146
- if (this.selectedIndex >= this.items.length) {
147
- this.selectedIndex = Math.max(0, this.items.length - 1);
148
- }
149
- }
150
-
151
- // -------------------------------------------------------------------------
152
- // Input
153
- // -------------------------------------------------------------------------
154
-
155
- handleInput(key: string): boolean {
156
- switch (key) {
157
- case 'up':
158
- case 'k': {
159
- if (this.selectedIndex > 0) {
160
- this.selectedIndex--;
161
- this.markDirty();
162
- }
163
- return true;
164
- }
165
- case 'down':
166
- case 'j': {
167
- if (this.selectedIndex < this.items.length - 1) {
168
- this.selectedIndex++;
169
- this.markDirty();
170
- }
171
- return true;
172
- }
173
- case 'return':
174
- case ' ': {
175
- // Toggle enabled/disabled on selected task
176
- const item = this.items[this.selectedIndex];
177
- if (item?.kind === 'task') {
178
- void this.automationManager.setEnabled(item.task.id, !item.task.enabled).then(() => {
179
- this.rebuild();
180
- this.markDirty();
181
- });
182
- }
183
- return true;
184
- }
185
- case 'r': {
186
- // Trigger immediate run of selected task
187
- const item = this.items[this.selectedIndex];
188
- if (item?.kind === 'task') {
189
- this.automationManager.runNow(item.task.id).catch(() => {
190
- // Non-fatal — error logged by scheduler
191
- });
192
- this.rebuild();
193
- this.markDirty();
194
- }
195
- return true;
196
- }
197
- case 'R': {
198
- // Refresh the view
199
- this.rebuild();
200
- this.markDirty();
201
- return true;
202
- }
203
- default:
204
- return false;
205
- }
206
- }
207
-
208
- // -------------------------------------------------------------------------
209
- // Rendering
210
- // -------------------------------------------------------------------------
211
-
212
- override render(width: number, height: number): Line[] {
213
- const tasks = this.automationManager.listJobs();
214
- const enabled = tasks.filter((task: AutomationJob) => task.enabled).length;
215
- if (tasks.length === 0) {
216
- return buildPanelWorkspace(width, height, {
217
- title: ' Schedule',
218
- intro: 'Review recurring scheduled tasks, next run timing, recent history, and enablement state.',
219
- sections: [
220
- {
221
- lines: buildEmptyState(
222
- width,
223
- ' No scheduled tasks',
224
- 'Use /schedule add to create a recurring task. Scheduled runs and history will appear here.',
225
- [],
226
- DEFAULT_PANEL_PALETTE,
227
- ),
228
- },
229
- ],
230
- footerLines: [
231
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
232
- ],
233
- palette: DEFAULT_PANEL_PALETTE,
234
- });
235
- }
236
-
237
- const taskItems = this.items.filter((item): item is Extract<ViewItem, { kind: 'task' }> => item.kind === 'task');
238
- this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, taskItems.length - 1));
239
- const summarySection: PanelWorkspaceSection = {
240
- title: 'Summary',
241
- lines: [
242
- buildPanelLine(width, [
243
- [' Tasks ', DEFAULT_PANEL_PALETTE.label],
244
- [String(tasks.length), DEFAULT_PANEL_PALETTE.value],
245
- [' Enabled ', DEFAULT_PANEL_PALETTE.label],
246
- [String(enabled), enabled > 0 ? DEFAULT_PANEL_PALETTE.good : DEFAULT_PANEL_PALETTE.dim],
247
- ]),
248
- ],
249
- };
250
- const scheduledTasksSection = resolveScrollablePanelSection(width, height, {
251
- intro: 'Review recurring scheduled tasks, next run timing, recent history, and enablement state.',
252
- footerLines: [
253
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
254
- ],
255
- palette: DEFAULT_PANEL_PALETTE,
256
- beforeSections: [summarySection],
257
- section: {
258
- title: 'Scheduled Tasks',
259
- scrollableLines: taskItems.flatMap((item, index) => this.renderTask(item.task, item.history, index === this.selectedIndex, width)),
260
- selectedIndex: this.selectedIndex * 3,
261
- scrollOffset: this.scrollOffset,
262
- minRows: 6,
263
- },
264
- });
265
- this.scrollOffset = scheduledTasksSection.scrollOffset;
266
- const sections: PanelWorkspaceSection[] = [
267
- summarySection,
268
- scheduledTasksSection.section,
269
- ];
270
-
271
- return buildPanelWorkspace(width, height, {
272
- title: ' Schedule',
273
- intro: 'Review recurring scheduled tasks, next run timing, recent history, and enablement state.',
274
- sections,
275
- footerLines: [
276
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
277
- ],
278
- palette: DEFAULT_PANEL_PALETTE,
279
- });
280
- }
281
-
282
- /**
283
- * Render a task as 3 rows:
284
- * Row 1: [status] id name schedule
285
- * Row 2: next: <date> last: <date> runs: N
286
- * Row 3: prompt preview [history]
287
- */
288
- private renderTask(task: AutomationJob, history: AutomationRun[], selected: boolean, width: number): Line[] {
289
- const bg = selected ? C.selectBg : undefined;
290
- const fgBase = selected ? C.selectedFg : undefined;
291
-
292
- const bullet = task.enabled ? '* ' : 'o ';
293
- const bulletFg = task.enabled ? C.enabled : C.disabled;
294
- const nameStr = task.name.length > 28 ? task.name.slice(0, 25) + '...' : task.name.padEnd(28);
295
- const scheduleText = formatSchedule(task.schedule);
296
- const row1 = buildPanelLine(width, [
297
- [bullet, bulletFg, bg],
298
- [task.id.slice(0, 12), fgBase ?? C.id, bg],
299
- [' ', fgBase ?? C.prompt, bg],
300
- [nameStr, fgBase ?? C.prompt, bg],
301
- [' ', fgBase ?? C.prompt, bg],
302
- [scheduleText, fgBase ?? C.cron, bg],
303
- ]);
304
-
305
- const indent = ' ';
306
- const nextStr = task.nextRunAt
307
- ? `next: ${new Date(task.nextRunAt).toLocaleString()}`
308
- : 'next: unknown';
309
- const lastStr = task.lastRunAt
310
- ? `last: ${new Date(task.lastRunAt).toLocaleString()}`
311
- : 'last: never';
312
- const row2 = buildPanelLine(width, [
313
- [indent, fgBase ?? C.prompt, bg],
314
- [nextStr.padEnd(36), fgBase ?? C.nextRun, bg],
315
- [lastStr.padEnd(32), fgBase ?? C.lastRun, bg],
316
- [`runs: ${task.runCount}`, fgBase ?? C.runCount, bg],
317
- ]);
318
-
319
- const maxPromptLen = Math.max(20, width - indent.length - 30);
320
- const prompt = task.execution.prompt ?? task.description ?? '';
321
- const promptPreview = prompt.length > maxPromptLen
322
- ? prompt.slice(0, maxPromptLen - 1) + '\u2026'
323
- : prompt;
324
-
325
- // Show last 3 run statuses as colored dots
326
- const recentRuns = history.slice(-3);
327
- const runSegments = recentRuns.flatMap((run) => {
328
- const dotFg = run.status === 'failed' ? C.statusFailed : C.statusRunning;
329
- return [['\u25cf', dotFg, bg] as [string, string, string?]];
330
- });
331
- const row3 = buildPanelLine(width, [
332
- [indent, fgBase ?? C.prompt, bg],
333
- [promptPreview, fgBase ?? C.prompt, bg],
334
- ...(runSegments.length > 0 ? [[' ', fgBase ?? C.prompt, bg] as [string, string, string?], ...runSegments] : []),
335
- ]);
336
-
337
- // spacer row between tasks
338
- const spacer = buildPanelLine(width, [['', fgBase ?? C.prompt, bg]]);
339
-
340
- return [row1, row2, row3, spacer];
341
- }
342
- }