-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathdashboard.ts
More file actions
1589 lines (1514 loc) · 88 KB
/
Copy pathdashboard.ts
File metadata and controls
1589 lines (1514 loc) · 88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* The live `/dispatch` dashboard overlay: a self-refreshing TUI panel over the same read-model the slash
* commands use. It holds ONE queue and ONE redis client for its whole lifetime and polls them on a fixed
* interval -- the self-closing read-model wrappers are one-shots for a single command, so a per-second
* tick through them would open and drop a connection every second.
*
* The render never blocks on I/O: a background fetch writes the latest snapshot and the component always
* renders the last one, so a slow or unreachable queue degrades the panel rather than freezing it. A
* fetch already in flight suppresses the next tick's fetch, so a stall cannot stack overlapping reads.
*
* The in-component views sharing this one overlay: LIST -- a framed panel of status, spend, the unified
* TRIGGERS pane and an interactive runs list; RUN_DETAIL -- a drill-in of one run's PII-free `.json`
* fields; LIVE_TAIL -- a tail of a running job's `.log`; and TRIGGER_DETAIL -- one trigger's trust
* model. Analytics live on the insights page (issue #181): the `i` key writes and opens it.
*
* PII discipline (no-pii-in-logs, INT-RUN-HISTORY-FILE-CONTRACT): LIST and RUN_DETAIL surface only
* PII-free run records, counts, budget, schedulers and the settings overlay. LIVE_TAIL renders tail bytes
* obtained through the injected `tailLog` seam whose `fs` access lives in index.ts, so this module never
* touches the filesystem -- the bytes reach the overlay alone, never `snapshot`, never a shared renderer,
* never a message.
*/
import { dayKey, weekKey, monthKey, tokenDayKey, windowState } from "@edgehero/pi-dispatch/budget";
import { parseConnection, makeRedisClient } from "@edgehero/pi-dispatch/connection";
import { makeQueue } from "@edgehero/pi-dispatch/queue";
import { STALL_KEY } from "@edgehero/pi-dispatch/scheduler-stall-guard";
import { windowEndAt } from "@edgehero/pi-dispatch/pause-windows";
import { listRuns, readSettingsView, mapSchedulers, readTriggers, readPauseWindows, readStagedPackages } from "./read-model.mjs";
import { renderStatus, renderBudget, renderTriggers, renderSettingsView } from "./render.mjs";
import { matchesKey } from "./keys.mjs";
import { box, meter, clip, makeLineInput } from "./panel.mjs";
import { makeStyler, frame, RULE } from "./style.mjs";
const KEY_HINTS = "[p]ause [r]esume [q]uit";
// Fetch the read-model's full window (listRuns clamps at 50) but render a cursor-following viewport of
// RUNS_VIEWPORT rows -- runs 11..50 must be reachable without the frame growing 40 rows taller.
const RUNS_ON_DASHBOARD = 50;
const RUNS_VIEWPORT = 10;
// The runs-list orderings `o` cycles through. "time" is listRuns' own endedAt-descending order; the other
// three re-sort the records already in the snapshot and never re-read.
const RUN_SORTS = ["time", "tokens", "cost", "outcome"];
const REFRESH_MS = 1000;
// Lines requested per tail fetch, and the on-screen window of them the LIVE_TAIL view scrolls through.
const TAIL_LINES = 200;
const TAIL_VIEWPORT = 20;
// panel.mjs floors a box to this width; below it (or a missing/non-finite width) the panel degrades to
// unframed plain lines rather than a ragged or over-width frame.
const MIN_WIDTH = 8;
// Drill-in views (TRIGGER_DETAIL, RUN_DETAIL) are small; they frame to a compact width and center within
// the wider overlay rather than stretching a handful of key/value lines across the full LIST width.
const DRILL_WIDTH = 70;
/** Left-pad each line to center a `blockWidth`-wide frame within the `overlayWidth` overlay. */
function centerBlock(lines: string[], overlayWidth: number, blockWidth: number): string[] {
const pad = Math.max(0, Math.floor((overlayWidth - blockWidth) / 2));
if (pad === 0) return lines;
const prefix = " ".repeat(pad);
return lines.map((l) => prefix + l);
}
/**
* Build the read/act/close deps for a live dashboard from resolved paths: ONE failFast queue and ONE
* redis client, both created here and closed once in `dispose`. `fetchSnapshot` reads the whole panel in
* one pass off those held connections; `pause`/`resume` flip the durable paused state on the same queue.
* `getWorkers` is EMPTY on Redis providers without CLIENT SETNAME, so an error or empty list degrades to
* "unknown" rather than reporting zero live workers.
*/
export function createDashboardDeps(paths: any) {
const queue = makeQueue(parseConnection(paths.valkeyUrl, { failFast: true }));
const redis = makeRedisClient(paths.valkeyUrl);
return {
async fetchSnapshot() {
const [pausedState, counts, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerList, activeList, stallHash] = await Promise.all([
queue.isPaused(),
queue.getJobCounts("waiting", "active", "paused", "delayed", "failed"),
queue.getWorkers().catch(() => []),
redis.get(dayKey()),
redis.get(weekKey()),
redis.get(monthKey()),
redis.get(tokenDayKey()), // issue #25 daily token spend (budget:t:YYYY-MM-DD)
queue.getJobSchedulers(0, -1, true),
queue.getActive(0, 0).catch(() => []),
// Per-scheduler stall counts (money backstop) for the cron drill-in; reuses the held client like the
// budget GETs. HGETALL of an absent key is `{}`, so a never-stalled deployment shows 0 stalls.
redis.hgetall(STALL_KEY).catch(() => ({})),
]);
const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
return {
queue: { pausedState, counts, workers },
budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
schedulers: mapSchedulers(schedulerList, Date.now()),
schedulerStalls: stallHash ?? {},
schedulerStallMax: paths.schedulerStallMax,
runs: listRuns({ logsDir: paths.logsDir, limit: RUNS_ON_DASHBOARD }),
settings: readSettingsView({ settingsFile: paths.settingsFile }),
triggers: readTriggers({ triggersPath: paths.triggersPath }),
pauseWindows: readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath }),
// The operator's staged third-party pi packages (REQ-GLOBAL-PI-OVERLAY), for the armed triggers'
// trust model. Like the four reads above it is a plain file read whose fs access lives entirely in
// read-model.mjs -- this module never touches the filesystem -- and it degrades to a safe empty
// shape rather than throwing, so a broken overlay cannot take the whole snapshot down.
stagedPackages: readStagedPackages({ globalPiDir: paths.globalPiDir }),
// ONLY the id off the active Job -- a Job's `.data` holds issue title/body/username (PII), so it
// never enters the snapshot (no-pii-in-logs, INT-RUN-HISTORY-FILE-CONTRACT).
activeJobId: activeList?.[0]?.id ?? null,
};
},
async pause() {
await queue.pause();
},
async resume() {
await queue.resume();
},
async dispose() {
try {
await queue.close();
} catch {
// best-effort teardown
}
try {
redis.disconnect();
} catch {
// best-effort teardown
}
},
};
}
/**
* The dashboard overlay component. `deps` is the one injection seam: production defaults to a real
* `createDashboardDeps(paths)` (one queue + one redis for the panel's lifetime); tests pass a canned
* `fetchSnapshot` and `pause`/`resume`/`dispose` spies and never touch Redis. The first fetch fires
* immediately so the panel is populated before the first interval tick; every fetch requests a re-render.
*/
export function makeDashboard({
paths,
done,
tui,
theme,
intervalMs = REFRESH_MS,
deps = createDashboardDeps(paths),
}: any = {}) {
// The overlay-only color styler, bound to pi's injected theme (null in tests -> plain, same geometry).
// The ascii opt-in rides the same resolved paths as panel.mjs' setGlyphs funnel, so PI_DISPATCH_ASCII=1
// degrades the overlay frame and the panel primitives TOGETHER -- half-ASCII output was the pending gap
// the old comment here deferred (issue #54's works-in-ASCII acceptance is what landed it).
const styler = makeStyler(theme, { ascii: paths?.asciiGlyphs === true });
let snapshot: any = null;
let fetching = false;
let disposed = false;
let interval: any = null;
// In-component view machine: LIST is the framed panel with the interactive run list; RUN_DETAIL is the
// single-record dump; LIVE_TAIL tails a running job's `.log` inside the overlay. `selected` is the list
// cursor; `detailRun` is the record captured on Enter.
let view = "LIST";
let selected = 0;
let detailRun: any = null;
// REQ-RESURRECTABLE-SANDBOX: whether the run opened in RUN_DETAIL still has a retained workspace, read
// ONCE on entry through the injected seam rather than on every tick -- the answer changes at worker boot,
// not per second, and this module does no I/O of its own.
let detailSandbox: any = null;
let detailTrigger: any = null; // the trigger opened in TRIGGER_DETAIL (its display record + file index)
// LIVE_TAIL state, held here in dedicated component fields keyed only by the id-only `activeJobId`. The
// raw `.log` bytes in `tail` are PII-bearing and untrusted: they live here and reach the TUI overlay via
// render() alone -- never `snapshot`, never a shared renderer, never `sendMessage` (INT-RUN-HISTORY-FILE-CONTRACT).
let tailJobId: any = null;
let tail: any = null;
let tailTop = 0;
// Follow mode: the tail opens pinned to the BOTTOM (the newest lines are what the view is opened for)
// and stays pinned as the log grows. Scrolling up pauses following; scrolling back to the bottom
// re-arms it. The footer names the state, so a paused tail cannot masquerade as a live one.
let tailFollow = true;
// LIVE_TAIL search: `tailSearchInput` is the open `/` line input (null when closed), `tailQuery` the
// armed case-insensitive substring (null when none), `tailMatchLine` the absolute index of the current
// match in the tail. They live beside the other tail fields and reset with them on Esc-to-LIST; the
// query only ever matches over the same held `tail` bytes the view already renders, so search adds no
// new surface for the untrusted log to reach.
let tailSearchInput: any = null;
let tailQuery: any = null;
let tailMatchLine: any = null;
// RUN_DETAIL's transient clipboard acknowledgment: set by y/Y, cleared by the NEXT handleInput or
// refresh, so it renders for exactly the frames between two inputs -- simple and test-observable.
let copiedNote: any = null;
// The active runs-list ordering (`o` cycles RUN_SORTS); named in the runs divider so the list is never
// silently re-ordered.
let runSort = "time";
// TRIGGER_DETAIL: `x` armed a y/n confirm rendered in the frame's own footer, so the question costs a
// keystroke rather than a dispose/reopen cycle of the whole overlay. Only the `y` closes the overlay,
// carrying `confirmed: true` so the command loop does not ask the same question twice.
let pendingDelete = false;
const refresh = async () => {
if (fetching || disposed) return;
fetching = true;
copiedNote = null; // the copy note is one-frame-transient: any refresh outdates it
try {
snapshot = await deps.fetchSnapshot();
// Only while the tail view is open, and only through the injected capability, re-read the tail keyed
// by the id-only `tailJobId`. `await` unwraps a synchronous return too. The bytes stay in `tail`.
if (view === "LIVE_TAIL" && tailJobId && deps.tailLog) {
tail = await deps.tailLog({ jobId: tailJobId, lines: TAIL_LINES });
}
} catch (err: any) {
snapshot = { unreachable: err?.message ?? String(err) };
} finally {
fetching = false;
tui?.requestRender?.();
}
};
const act = async (action: () => Promise<void>) => {
try {
await action();
} catch {
// A failed pause/resume surfaces as the next snapshot's paused state; never crash the overlay.
}
await refresh();
};
const dispose = async () => {
if (disposed) return;
disposed = true;
if (interval !== null) {
clearInterval(interval);
interval = null;
}
try {
await deps.dispose();
} catch {
// best-effort teardown
}
};
interval = setInterval(() => void refresh(), intervalMs);
interval?.unref?.(); // never keep the process alive on the poll timer alone (dispose still clears it)
void refresh();
const component = {
render(width: number): string[] {
// Clamp the cursor here so a rows list that shrank between ticks can never leave `selected` pointing
// past the end. `rows` spans the optional ACTIVE row plus the run records.
const rows = buildRows(snapshot, runSort);
if (selected > rows.length - 1) selected = Math.max(0, rows.length - 1);
// Clamp the tail scroll so a shrinking log can never scroll past the end; follow mode instead pins
// the window to the bottom of every fresh tail.
if (view === "LIVE_TAIL") {
const len = Array.isArray(tail?.lines) ? tail.lines.length : 0;
const maxTop = Math.max(0, len - TAIL_VIEWPORT);
tailTop = tailFollow ? maxTop : Math.min(Math.max(0, tailTop), maxTop);
}
return renderPanel(snapshot, width, {
view,
selected,
detailRun,
detailTrigger,
tailJobId,
tail,
tailTop,
tailFollow,
tailAvailable: typeof deps?.tailLog === "function",
tailSearchInput,
tailQuery,
tailMatchLine,
detailSandbox,
sandboxAvailable: typeof deps?.launchSandbox === "function",
pendingDelete,
runSort,
copiedNote,
copyAvailable: typeof deps?.copyText === "function",
// Height through the injected seam, read per frame (a resize changes it): null (seam absent, or
// stdout not a TTY) means the collapse budget stays off and the panel composes exactly as before.
terminalRows: typeof deps?.terminalRows === "function" ? deps.terminalRows() : null,
}, styler);
},
invalidate(): void {
// No cached render state to clear; the TUI redraws from render().
},
handleInput(data: string): void {
// Whatever key arrives next outdates the copy acknowledgment; y/Y below set a fresh one AFTER this
// line, so the note lives for exactly the renders between two inputs.
copiedNote = null;
if (view === "RUN_DETAIL") {
// Escape backs out to the list only; it never closes the overlay or disposes the held clients.
if (matchesKey(data, "escape")) {
view = "LIST";
detailSandbox = null;
tui?.requestRender?.();
return;
}
// ←/→ walk the run records in place: post-mortems are read in sequence, and Esc-arrow-Enter per
// record is the tax this removes. The LIST cursor follows, so Esc lands on the run being read.
if (matchesKey(data, "left") || matchesKey(data, "right")) {
const rows = buildRows(snapshot, runSort);
const i = rows.findIndex((r) => r.kind === "run" && r.record?.jobId && r.record.jobId === detailRun?.jobId);
if (i === -1) return;
const step = matchesKey(data, "left") ? -1 : 1;
const next = rows[i + step];
if (!next || next.kind !== "run") return;
detailRun = next.record;
// Same one-read-on-entry rule as Enter: sandbox state through the injected seam, never per tick.
detailSandbox = typeof deps?.sandboxInfo === "function" ? deps.sandboxInfo({ jobId: detailRun?.jobId }) : null;
selected = i + step;
tui?.requestRender?.();
return;
}
// `b` re-opens this run's sandbox as a shell (REQ-RESURRECTABLE-SANDBOX).
//
// Launched IN PLACE, unlike TRIGGER_DETAIL's `e`/`x`, which resolve the overlay so ctx.ui dialogs
// can run after it closes. This needs the opposite: the LIVE `tui`, because handing the terminal to
// an interactive container means suspending pi's own render loop and input handling for the
// duration and restoring them after. `stop()`/`start()` are pi's designed pair for exactly that --
// it uses them itself to launch $EDITOR -- and `requestRender(true)` forces the full redraw that
// an external program's output has invalidated.
//
// The spawn itself is the injected `launchSandbox`; this module holds the tui and nothing else.
if (data === "b" || data === "B") {
if (typeof deps?.launchSandbox !== "function" || !detailRun?.jobId) return;
if (detailSandbox && detailSandbox.retained === false) return; // nothing to open; the view says so
void (async () => {
tui?.stop?.();
try {
await deps.launchSandbox({ jobId: detailRun.jobId });
} catch {
// A failed launch must not leave the panel suspended -- the `finally` is the whole guarantee.
} finally {
tui?.start?.();
tui?.requestRender?.(true);
}
})();
return;
}
// OSC 52 copy, bound only while the seam is wired (the terminal write lives in index.ts beside
// tailLog). `y` takes the job id -- the handle every other command keys off -- and `Y` the
// browsable target URL when the record's forge yields one (github only; see targetUrl). Both are
// operator-initiated, id-only strings; the acknowledgment rides the footer until the next input.
if ((data === "y" || data === "Y") && typeof deps?.copyText === "function") {
const text = data === "y" ? detailRun?.jobId : targetUrl(detailRun);
if (!text) return; // nothing to copy: inert, no note
deps.copyText(String(text));
copiedNote = `copied ${data === "y" ? "job id" : "target url"}`;
tui?.requestRender?.();
return;
}
// Every other key (including q/p/r) is inert in the detail view.
return;
}
if (view === "TRIGGER_DETAIL") {
// Read-only trust-model view. `e` edits the flow via the command loop's dialogs; `x` arms an
// in-frame y/n whose `y` alone closes the overlay with the delete action (carrying `confirmed`,
// so the command loop does not ask the same question twice); Esc backs out to the list.
if (pendingDelete) {
if (data === "y" || data === "Y") {
void dispose().finally(() => done({ action: "deleteTrigger", index: detailTrigger?.index, confirmed: true }));
return;
}
// An explicit decline (or Esc) stands down; every other key is inert while the question is up,
// so a buffered keystroke cannot answer it by accident.
if (data === "n" || data === "N" || matchesKey(data, "escape")) {
pendingDelete = false;
tui?.requestRender?.();
}
return;
}
if (matchesKey(data, "escape")) {
view = "LIST";
detailTrigger = null;
tui?.requestRender?.();
return;
}
if (data === "e" || data === "E") {
void dispose().finally(() => done({ action: "editTrigger", index: detailTrigger?.index }));
return;
}
if (data === "x" || data === "X") {
pendingDelete = true;
tui?.requestRender?.();
return;
}
return;
}
if (view === "LIVE_TAIL") {
// The `/` search input is the innermost layer, routed BEFORE every view key: a printable byte
// must land in the query, never fire a scroll or view key.
if (tailSearchInput) {
if (matchesKey(data, "escape")) {
// Esc closes the SEARCH -- input and armed query together -- one layer above the view's own
// Esc-to-LIST below, matching the pop-one-layer discipline everywhere else in the overlay.
tailSearchInput = null;
tailQuery = null;
tailMatchLine = null;
tui?.requestRender?.();
return;
}
if (data === "\r" || data === "\n") {
// Enter closes the input keeping the query armed; an empty query arms nothing.
const q = tailSearchInput.value();
tailQuery = q.length > 0 ? q : null;
if (tailQuery === null) tailMatchLine = null;
tailSearchInput = null;
tui?.requestRender?.();
return;
}
if (matchesKey(data, "backspace")) tailSearchInput.backspace();
else if (matchesKey(data, "left")) tailSearchInput.left();
else if (matchesKey(data, "right")) tailSearchInput.right();
else if (matchesKey(data, "home")) tailSearchInput.home();
else if (matchesKey(data, "end")) tailSearchInput.end();
else if (!data.startsWith("\x1b") && data >= " ") tailSearchInput.insert(data);
else return; // any other control sequence is inert while the search is up
tui?.requestRender?.();
return;
}
// Escape pops ONE layer: an armed query first, the view second. Backing out to the list drops the
// held tail bytes AND the search state; scroll keys move the window. Every other key is inert.
// This view never closes the overlay or disposes the held clients.
if (matchesKey(data, "escape")) {
if (tailQuery !== null) {
tailQuery = null;
tailMatchLine = null;
tui?.requestRender?.();
return;
}
view = "LIST";
tailJobId = null;
tail = null;
tailTop = 0;
tailFollow = true;
tailSearchInput = null;
tailMatchLine = null;
tui?.requestRender?.();
return;
}
if (data === "/") {
// Seeded with the armed query (empty when none), so reopening the bar refines rather than
// restarts; Enter re-arms whatever it says.
tailSearchInput = makeLineInput(tailQuery ?? "");
tui?.requestRender?.();
return;
}
// Scrolling up pauses follow mode at the current window; reaching the bottom again re-arms it.
const len = Array.isArray(tail?.lines) ? tail.lines.length : 0;
const maxTop = Math.max(0, len - TAIL_VIEWPORT);
// With a query armed, n/N jump to the next/previous matching line (wrapping). A matched jump is
// manual scrolling in every way that matters, so it suspends follow exactly as the arrows do; with
// no match there is nothing to jump to and the footer already says `no match`.
if ((data === "n" || data === "N") && tailQuery !== null) {
const all = Array.isArray(tail?.lines) ? tail.lines : [];
const matches = tailMatches(all, tailQuery);
if (matches.length === 0) return;
const from = tailMatchLine === null ? tailTop : tailMatchLine;
tailMatchLine =
data === "n"
? matches.find((i) => i > from) ?? matches[0]
: [...matches].reverse().find((i) => i < from) ?? matches[matches.length - 1];
tailFollow = false;
tailTop = Math.min(tailMatchLine, maxTop);
tui?.requestRender?.();
return;
}
if (matchesKey(data, "up")) {
tailFollow = false;
tailTop = Math.max(0, tailTop - 1);
tui?.requestRender?.();
} else if (matchesKey(data, "down")) {
tailTop = Math.min(maxTop, tailTop + 1);
if (tailTop >= maxTop) tailFollow = true;
tui?.requestRender?.();
} else if (matchesKey(data, "pageUp")) {
tailFollow = false;
tailTop = Math.max(0, tailTop - TAIL_VIEWPORT);
tui?.requestRender?.();
} else if (matchesKey(data, "pageDown")) {
tailTop = Math.min(maxTop, tailTop + TAIL_VIEWPORT);
if (tailTop >= maxTop) tailFollow = true;
tui?.requestRender?.();
}
return;
}
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
void dispose().finally(() => done(undefined));
return;
}
if (data === "\r" || data === "\n") {
const rows = buildRows(snapshot, runSort);
const row = rows[selected];
if (!row) return;
if (row.kind === "trigger") {
detailTrigger = { record: row.trigger, index: row.index };
pendingDelete = false;
view = "TRIGGER_DETAIL";
tui?.requestRender?.();
} else if (row.kind === "active") {
// Opening the tail: fire an immediate fetch so the first frame carries the tail, not the next tick.
tailJobId = row.jobId;
tailTop = 0;
tailFollow = true;
view = "LIVE_TAIL";
void refresh();
} else {
detailRun = row.record;
// One read on entry, through the seam whose fs access lives in index.ts.
detailSandbox = typeof deps?.sandboxInfo === "function" ? deps.sandboxInfo({ jobId: row.record?.jobId }) : null;
view = "RUN_DETAIL";
tui?.requestRender?.();
}
return;
}
// `l` -- the footer's logs key: jump straight to the live tail when a job is running (the same path
// Enter takes on the ACTIVE row); inert otherwise, because there is no log to open.
if (data === "l" || data === "L") {
if (!snapshot?.activeJobId) return;
tailJobId = snapshot.activeJobId;
tailTop = 0;
tailFollow = true;
view = "LIVE_TAIL";
void refresh();
return;
}
// `i` -- the insights page (issue #181): analytics live in the browser artifact now, so the key
// resolves the overlay with a done-action, index.ts writes and opens the page between overlays
// (the addTrigger route -- no dep seam here, no TUI suspend bracket), and the panel reopens.
if (data === "i" || data === "I") {
void dispose().finally(() => done({ action: "openInsights" }));
return;
}
// Tab jumps between the two section heads (triggers <-> runs) instead of arrowing through every row.
if (data === "\t") {
const rows = buildRows(snapshot, runSort);
if (rows.length === 0) return;
const trgCount = (snapshot?.triggers?.triggers ?? []).length;
selected = selected < trgCount && trgCount < rows.length ? trgCount : 0;
tui?.requestRender?.();
return;
}
// `o` cycles the runs-list order; the runs divider names the active one, so the list is never
// silently re-ordered. Sorting reads the records already in the snapshot -- no re-read.
if (data === "o" || data === "O") {
runSort = RUN_SORTS[(RUN_SORTS.indexOf(runSort) + 1) % RUN_SORTS.length];
tui?.requestRender?.();
return;
}
// CRUD (operator-typed, live via the reload watchers): add a trigger, or edit the limits/settings.
// Both close the overlay with an action the command loop drives via ctx.ui dialogs, then reopen.
if (data === "a" || data === "A") {
void dispose().finally(() => done({ action: "addTrigger" }));
return;
}
if (data === "s" || data === "S") {
void dispose().finally(() => done({ action: "editSettings" }));
return;
}
if (data === "w" || data === "W") {
void dispose().finally(() => done({ action: "managePauses" }));
return;
}
if (matchesKey(data, "up")) {
selected = Math.max(0, selected - 1);
tui?.requestRender?.();
return;
}
if (matchesKey(data, "down")) {
selected = Math.min(Math.max(0, buildRows(snapshot, runSort).length - 1), selected + 1);
tui?.requestRender?.();
return;
}
if (data === "p" || data === "P") {
void act(deps.pause);
return;
}
if (data === "r" || data === "R") {
void act(deps.resume);
}
},
dispose,
};
return component;
}
/** Split a renderer's multi-line string into the per-line array a `box` section expects. */
function toLines(text: string): string[] {
return String(text).split("\n");
}
/**
* One meter per spend window whose cap the admin can read (the overlay sets it). The day meter always shows
* (parity with the single-window panel); week/month meters show only when their overlay cap is set. Each
* meter's state comes from the worker's own `windowState`, so the bar's amber/red marker cannot drift from
* what `reserveBudget` enforces. `meter` renders "cap unknown" for a window with no readable cap, so a
* missing overlay cap degrades in place rather than guessing a denominator.
*/
function budgetMeters(budget: any, settings: any, width: number): string[] {
const overlay = settings?.overlay ?? {};
const pct = Number.isInteger(overlay.softHoldPct) ? overlay.softHoldPct : null;
const specs = [
{ key: "day", cap: overlay.dailyCap, always: true },
{ key: "week", cap: overlay.weeklyCap, always: false },
{ key: "month", cap: overlay.monthlyCap, always: false },
];
const out: string[] = [];
for (const s of specs) {
if (!s.always && !Number.isInteger(s.cap)) continue;
const reserved = Number(budget?.[s.key] ?? 0);
const state = Number.isInteger(s.cap) ? windowState(reserved, s.cap, pct) : "ok";
out.push(meter(reserved, s.cap, width, state));
}
return out;
}
/**
* Compose the monochrome framed panel from the last snapshot alone, reusing the slash-command renderers so
* the panel and the commands cannot drift. A null snapshot is the pre-first-fetch loading state; a snapshot
* carrying `unreachable` degrades the whole panel to one line rather than a wall of empty sections. A width
* that is missing, non-finite, or below `MIN_WIDTH` degrades to unframed plain lines; a sane width frames
* the same content with `box`, its inner column count driving every meter and clip.
*/
function renderPanel(snapshot: any, width: number, state: any, styler: any): string[] {
const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailFollow, tailAvailable, tailSearchInput, tailQuery, tailMatchLine, detailSandbox, sandboxAvailable, pendingDelete, runSort, copiedNote, copyAvailable, terminalRows } = state;
const framed = Number.isFinite(width) && Math.trunc(width) >= MIN_WIDTH;
const inner = Math.trunc(width) - 4;
const title = "pi-dispatch";
if (view === "TRIGGER_DETAIL") {
const t = detailTrigger?.record;
const detailTitle = `trigger · ${t?.type ?? "?"}`;
const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
const sched = cronSchedInfo(t, snapshot);
const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched, snapshot?.stagedPackages);
if (!framed) return [detailTitle, "", ...lines.map((l: string) => styler.stripAnsi(l)), "", pendingDelete ? "delete this trigger? y/n" : "e edit · x delete · esc back"];
const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: triggerDetailHints(dw - 4, styler, pendingDelete) });
return centerBlock(boxed, Math.trunc(width), dw);
}
if (view === "RUN_DETAIL") {
const detailTitle = `run ${detailRun?.jobId ?? "-"}`;
const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
const allRuns = Array.isArray(snapshot?.runs) ? snapshot.runs : [];
const canOpen = Boolean(sandboxAvailable && detailSandbox?.retained);
const canCopy = Boolean(copyAvailable);
const lines = renderRunDetail(detailRun, framed ? dw - 4 : 24, styler, allRuns, detailSandbox);
if (!framed) {
const bits = ["←→ prev/next"];
if (canOpen) bits.push("b sandbox");
if (canCopy) bits.push("y copy");
bits.push("esc back");
return [detailTitle, "", ...lines.map((l: string) => styler.stripAnsi(l)), "", (copiedNote ? `${copiedNote} · ` : "") + bits.join(" · ")];
}
const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: runDetailHints(dw - 4, styler, canOpen, canCopy, copiedNote) });
return centerBlock(boxed, Math.trunc(width), dw);
}
if (view === "LIVE_TAIL") {
return renderLiveTail({ snapshot, framed, width, tailJobId, tail, tailTop, tailFollow, tailAvailable, tailSearchInput, tailQuery, tailMatchLine, styler });
}
if (snapshot === null) {
if (!framed) return [`${title} -- loading`, "", KEY_HINTS];
return box({ title, sections: [{ lines: ["loading"] }], footer: KEY_HINTS, width });
}
if (snapshot.unreachable) {
const msg = `unreachable (${snapshot.unreachable})`;
if (!framed) return [`${title} -- ${msg}`, "", KEY_HINTS];
return box({ title, sections: [{ lines: [msg] }], footer: KEY_HINTS, width });
}
// LIST — the colored dashboard. Content is composed on PLAIN text (widths via styler.cell/visibleLen)
// and colored last, so pi's ANSI-aware visibleWidth frames it correctly. `terminalRows` (the injected
// height, when known) drives the section collapse budget in buildListLines; the degraded path below
// stays uncollapsed -- it is already the everything-else-failed rendering.
if (framed) {
const lines = buildListLines(snapshot, selected, inner, styler, runSort, terminalRows);
return frame(styler, { title, width, lines, footer: keyHints(inner, styler) });
}
// Degraded (too-narrow) plain path — reuse the shared, plain renderers unframed.
const sections = [
{ title: "STATUS", lines: toLines(renderStatus(snapshot.queue)) },
{
title: "SPEND",
lines: [
...toLines(renderBudget({ budget: snapshot.budget, settings: snapshot.settings })),
...budgetMeters(snapshot.budget, snapshot.settings, 24),
],
},
{ title: "TRIGGERS", lines: toLines(renderTriggers({ schedulers: snapshot.schedulers, triggers: snapshot.triggers })) },
{ title: "RUNS", lines: renderRunList(buildRows(snapshot, runSort), selected, 24) },
{ title: "SETTINGS", lines: toLines(renderSettingsView(snapshot.settings)) },
];
const plain = [title];
for (const section of sections) plain.push(section.title, ...section.lines);
plain.push(KEY_HINTS);
return plain.join("\n\n").split("\n");
}
// ── colored LIST builders (overlay-only; every returned line is exactly `inner` visible columns) ────────
const KIND_COLOR: Record<string, string> = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction" };
const KIND_WIDTH = 13; // fits "pull_request "
/** Pad an already-colored line up to `inner` visible columns; if it overflows, clip its plain form. */
function fitLine(line: string, inner: number, styler: any): string {
const vis = styler.visibleLen(line);
if (vis === inner) return line;
if (vis < inner) return line + " ".repeat(inner - vis);
return styler.cell(styler.stripAnsi(line), inner);
}
// The frame rows around a composed body -- top border, footer rule, footer, bottom border -- charged to
// the collapse budget before any section is measured. Kept beside the LIST budget because both
// frame with the same chrome.
const FRAME_CHROME_ROWS = 4;
/**
* The pure collapse decision for the LIST budget: which sections give way when the composed panel
* outgrows the terminal. `sections` carry `{ key, rows, keptRows, priority }`; `baseRows` is everything
* that never collapses (frame chrome, RULE separators, the fixed blocks); collapsing a section keeps
* `keptRows` of it -- LIST keeps the divider line. Sections
* fold in ascending `priority`, never the `focus`ed one and never one without a priority, until the total
* fits. A null/non-finite `availableRows` (seam absent, stdout not a TTY) collapses NOTHING, so an
* unknown height renders byte-identically to the panel before this existed. Best-effort on purpose: when
* everything foldable is folded the panel may still overflow, and the residue is the runs/tail viewport's
* own already-bounded height.
*/
function collapseKeys(sections: any[], availableRows: any, focus: string | null, baseRows: number): Set<string> {
const out = new Set<string>();
if (!Number.isFinite(availableRows)) return out;
let total = baseRows;
for (const s of sections) total += s.rows;
const order = sections
.filter((s) => Number.isFinite(s.priority) && s.key !== focus)
.sort((a, b) => a.priority - b.priority);
for (const s of order) {
if (total <= availableRows) break;
out.add(s.key);
total -= s.rows - s.keptRows;
}
return out;
}
/** Compose the colored LIST body lines (RULE marks a `├──┤` separator). With a known terminal height
* (`availableRows` through the injected seam) sections collapse by priority until the frame fits; an
* unknown height composes exactly the full panel it always did -- byte-identical by construction. */
function buildListLines(snapshot: any, selected: number, inner: number, styler: any, runSort = "time", availableRows: any = null): any[] {
// Triggers are selectable and come FIRST in buildRows, so a trigger's file index == its selection index.
const trg = triggerLines(snapshot, selected, inner, styler);
const pw = pauseLines(snapshot.pauseWindows, inner, styler);
// Active + run rows follow the triggers in buildRows, so offset the selection index by the trigger count.
const runRows = buildRows(snapshot, runSort).slice(trg.count);
const runCount = Array.isArray(snapshot.runs) ? snapshot.runs.length : 0;
// The panel as an ordered section model. `head` is the divider label+meta (null for the status header),
// `priority` the collapse order -- pause windows give way first, then settings, then triggers, then
// spend, roughly inverse to how often an operator acts on them from this panel -- and `viewKey` the key
// the collapsed divider names. Status and runs carry no priority: the header is the panel's one
// constant and the runs viewport already bounds itself.
const sections: any[] = [
{ key: "status", head: null, body: [statusHeader(snapshot.queue, inner, styler)] },
{ key: "spend", head: ["spend & limits", "jobs & tokens/day · s set"], body: spendLines(snapshot.budget, snapshot.settings, inner, styler), priority: 4, viewKey: "s" },
{ key: "triggers", head: ["triggers", `${trg.count} standing · a add · ↵ open`], body: trg.lines, priority: 3, viewKey: "tab" },
{ key: "pauses", head: ["pause windows", `${pw.count} · w manage`], body: pw.lines, priority: 1, viewKey: "w" },
{ key: "runs", head: ["runs", `last ${runCount} · o ${runSort}`], body: runLines(runRows, selected - trg.count, inner, styler) },
{ key: "settings", head: ["settings", "s edit"], body: settingsLines(snapshot.settings, inner, styler), priority: 2, viewKey: "s" },
];
// The cursor's section is never collapsed out from under it. Runs cannot collapse anyway; the rule is
// stated for both so Tab-into-triggers always re-expands them on the very next frame.
const focus = selected < trg.count ? "triggers" : "runs";
const collapsed = collapseKeys(
sections.map((s) => ({ key: s.key, rows: (s.head ? 1 : 0) + s.body.length, keptRows: 1, priority: s.priority })),
availableRows,
focus,
FRAME_CHROME_ROWS + sections.length - 1, // chrome + one RULE between each pair of sections
);
const lines: any[] = [];
sections.forEach((s, i) => {
if (i > 0) lines.push(RULE);
if (collapsed.has(s.key)) {
// The divider line alone, its meta now saying what folded away and which key gets it back.
lines.push(styler.divider(s.head[0], `(${s.body.length} hidden — ${s.viewKey} to view)`, inner));
return;
}
if (s.head) lines.push(styler.divider(s.head[0], s.head[1], inner));
for (const l of s.body) lines.push(l);
});
return lines;
}
/** The one-line STATUS header: `● RUNNING N waiting · … · K workers HH:MM:SS`. */
function statusHeader(queue: any, inner: number, styler: any): string {
if (!queue || queue.unreachable) {
return styler.cell(`queue unreachable (${queue?.unreachable ?? "?"})`, inner, { color: "error" });
}
const c = queue.counts ?? {};
const running = !queue.pausedState;
const failed = Number(c.failed ?? 0);
const stateColor = running ? "success" : "warning";
const dot = styler.fg(stateColor, "●");
const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
const sep = styler.fg("dim", " · ");
const vitals =
`${c.waiting ?? 0} waiting` + sep + `${c.active ?? 0} active` + sep +
(failed > 0 ? styler.fg("error", `${failed} failed`) : `${failed} failed`) + sep +
`${queue.workers ?? "?"} workers`;
const clock = new Date().toISOString().slice(11, 19); // HH:MM:SS UTC
const left = `${dot} ${word} ${vitals}`;
const gap = inner - styler.visibleLen(left) - clock.length;
if (gap < 1) return styler.cell(styler.stripAnsi(left), inner);
return left + " ".repeat(gap) + styler.fg("dim", clock);
}
/** Colored spend meters (day/week/month) with reset countdown + soft-hold marker. */
function spendLines(budget: any, settings: any, inner: number, styler: any): string[] {
if (!budget || budget.unreachable) {
return [styler.cell(`budget unreachable (${budget?.unreachable ?? "?"})`, inner, { color: "error" })];
}
const overlay = (settings && settings.overlay) ?? {};
const pct = Number.isInteger(overlay.softHoldPct) ? overlay.softHoldPct : null;
const now = new Date();
const specs = [
{ key: "day", label: "day", cap: overlay.dailyCap, reset: nextDayResetMs(now), always: true },
{ key: "week", label: "week", cap: overlay.weeklyCap, reset: nextWeekResetMs(now), always: false },
{ key: "month", label: "month", cap: overlay.monthlyCap, reset: nextMonthResetMs(now), always: false },
];
const out: string[] = [];
const labW = 6;
for (const s of specs) {
const reserved = Number(budget[s.key] ?? 0);
const capSet = Number.isInteger(s.cap);
// The day cap always applies (env default even when the overlay is silent). Week/month default to
// disabled, so when the overlay sets no cap and nothing has reserved, show them as an off, enableable
// window rather than hiding them — the operator sees every limit and which are switched off.
if (!capSet && !s.always && reserved === 0) {
out.push(styler.fg("muted", s.label.padEnd(labW)) + styler.fg("dim", "off · no cap set (s to enable)"));
continue;
}
const state = capSet ? windowState(reserved, s.cap, pct) : "ok";
const marker = state === "soft-hold" ? " · soft-hold" : state === "over" ? " · over" : "";
const tail = countdownText(s.reset) + marker;
const barW = Math.max(8, inner - labW - 2 - tail.length);
const line =
styler.cell(s.label, labW, { color: "muted" }) + " " +
styler.meter(reserved, s.cap, barW, state) + " " +
styler.fg("dim", tail);
out.push(fitLine(line, inner, styler));
}
if (pct !== null) out.push(styler.cell(`soft-hold band: ${pct}% of each cap`, inner, { color: "muted" }));
out.push(tokenLine(budget, overlay, pct, inner, styler));
return out;
}
/** The daily token counter (issue #25): today's spend vs the daily token cap, plus the per-job budget. */
function tokenLine(budget: any, overlay: any, pct: number | null, inner: number, styler: any): string {
const spent = Number(budget?.tokensToday ?? 0);
const cap = overlay?.dailyTokenCap;
const perJob = overlay?.maxTokens;
const perJobNote = Number.isInteger(perJob) ? ` · per-job ${fmtTokens(perJob)}` : " · per-job budget off";
const lab = styler.fg("muted", "tokens") + " "; // 6-wide label + space, matching the meter rows
if (Number.isInteger(cap)) {
const state = spent >= cap ? "over" : Number.isInteger(pct) && spent > Math.floor((cap * pct) / 100) ? "soft-hold" : "ok";
const color = state === "over" ? "error" : state === "soft-hold" ? "warning" : "success";
const marker = state === "soft-hold" ? " soft-hold" : state === "over" ? " over" : "";
return fitLine(lab + styler.fg(color, `${fmtTokens(spent)} / ${fmtTokens(cap)} today${marker}`) + styler.fg("dim", perJobNote), inner, styler);
}
return fitLine(lab + styler.fg("text", `${fmtTokens(spent)} today`) + styler.fg("dim", ` · daily cap off${perJobNote}`), inner, styler);
}
/** Compact token count: 1234 -> "1.2k", 1234567 -> "1.2M". */
function fmtTokens(n: number): string {
if (!Number.isFinite(n)) return "-";
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}k`;
return String(n);
}
/** The configured triggers as colored rows: `<kind> <match> → <target> <flow>`. Takes the whole snapshot
* (not just `snapshot.triggers`) so each cron row can join its resident scheduler for the health badge. */
function triggerLines(snapshot: any, selected: number, inner: number, styler: any): { count: number; lines: string[] } {
const triggers = snapshot?.triggers;
const lines: string[] = [];
if (triggers && triggers.missing) { lines.push(styler.cell("(triggers file not found · a to add)", inner, { color: "dim" })); return { count: 0, lines }; }
if (triggers && triggers.invalid) { lines.push(styler.cell(`(triggers file invalid: ${triggers.invalid})`, inner, { color: "error" })); return { count: 0, lines }; }
const list = (triggers && triggers.triggers) ?? [];
if (list.length === 0) { lines.push(styler.cell("(no triggers · a to add)", inner, { color: "dim" })); return { count: 0, lines }; }
list.forEach((t: any, i: number) => lines.push(triggerRow(t, i === selected, inner, styler, cronSchedInfo(t, snapshot))));
return { count: list.length, lines };
}
function triggerRow(t: any, sel: boolean, inner: number, styler: any, sched: any = null): string {
const cursor = sel ? styler.fg("accent", "›") : " ";
const kind = t?.type ?? "?";
const badge = styler.cell(kind, KIND_WIDTH, { color: KIND_COLOR[kind] ?? "muted" });
// A trigger that loads the operator-staged third-party pi packages says so: without this, a trigger
// running third-party code with open network egress renders identically to one that does not. Loading is
// the default (`run.packages` is an opt-out), so the badge is present unless the trigger declined.
// Amber, and appended AFTER the layout parts, so color stays post-layout.
// NO forge badge here, deliberately -- unlike render.mjs, which still needs one. This row's target now
// NAMES the forge (`-> gitlab fix`), so a badge would say it twice. The badge existed because the target
// used to read `-> github` for every forge, which made a gitlab row contradict its own badge; fixing the
// target removed the badge's reason to exist rather than merely its wrongness. render.mjs keeps its badge
// because its line goes straight to the flow (`-> fix`) and never names the forge at all.
const pkgs = t?.packages === true ? " " + styler.fg("warning", "[packages]") : "";
// A non-default image, in `accent` rather than `warning`: amber is reserved for the risk badge (third-party
// code), and an operator-built image is not third-party. `accent` is already this file's colour for "this
// trigger overrides a deployment default" -- the same choice the pinned-model row makes below.
const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
// `warning`, not `accent`: amber is this file's colour for a risk badge rather than for "overrides a
// deployment default", and persisting the agent's full working history to host disk -- issue text, file
// contents, tool output, its own reasoning -- is a disclosure, not a preference. Same class as
// [packages], which is the badge whose inverted polarity 0.1.4 had to fix.
const res = t?.resume === true ? " " + styler.fg("warning", "[resume]") : "";
// `warning` for the same reason [resume] is: a spend multiplier is a risk badge, not a preference. A
// trigger without it renders byte-identically -- the badge is purely additive, appended last.
const rep = t?.replicas > 1 ? " " + styler.fg("warning", `[x${t.replicas}]`) : "";
// Health is a LIST-level fact, not only a drill-in one: an overdue scheduler or a stall counter at the
// backstop max is exactly the row an operator must notice without opening it. Amber, appended last like
// the other risk badges; a healthy or non-cron row renders byte-identically to before.
const health = sched && (sched.overdueMs || (sched.stallMax > 0 && sched.stalls >= sched.stallMax))
? " " + styler.fg("warning", sched.overdueMs ? "⚠ overdue" : "⚠ stalled")
: "";
return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}${rep}${health}`, inner, styler);
}
function matchColored(t: any, styler: any): string {
switch (t?.type) {
case "cron": return styler.fg("text", `${t.id ?? "-"} ${t.pattern ?? "-"}`);
case "comment": return styler.fg("text", `"${t.phrase ?? "-"}"`);
case "label":
case "pull_request": {
const parts: string[] = [];
if (t.type === "pull_request") parts.push(styler.fg("muted", `[${(t.action ?? []).join(",")}]`));
for (const x of t.any ?? []) parts.push(styler.fg("success", x));
for (const x of t.all ?? []) parts.push(styler.fg("success", `+${x}`));
for (const x of t.none ?? []) parts.push(styler.fg("error", `!${x}`));
return parts.length ? parts.join(" ") : styler.fg("dim", "(any)");
}
default: return styler.fg("dim", "?");
}
}
function targetColored(t: any, styler: any): string {
const arrow = styler.fg("dim", "→");
const flow = styler.bold(styler.fg("text", t?.flow ?? "-"));
if (t?.type === "cron") {
// A local/cron trigger runs its flow against a folder — show `local <folder>/<flow>` so the target
// (not just the flow name) is visible; github triggers get their repo from the webhook, so none there.
const base = t.folder ? String(t.folder).split(/[/\\]/).filter(Boolean).pop() ?? "" : "";
const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
return `${arrow} ${styler.fg("success", "local")} ${folderPart}${flow}`;
}
// The forge is READ, never assumed. It was the literal "github" here, which rendered a gitlab or azure
// trigger as `→ github <flow> [gitlab]` -- the row contradicting its own badge. `forge` is carried
// verbatim by read-model.mjs, so an unrecognised one shows as itself rather than as a plausible default.
return `${arrow} ${styler.fg("accent", t?.forge ?? "github")} ${flow}`;
}
/** The scheduled pause windows as colored rows, each marked `●` (paused now, with a resume countdown) or `○`. */
function pauseLines(pauseWindows: any, inner: number, styler: any): { count: number; lines: string[] } {
const lines: string[] = [];
if (pauseWindows && pauseWindows.missing) { lines.push(styler.cell("(no pause windows · w to manage)", inner, { color: "dim" })); return { count: 0, lines }; }
if (pauseWindows && pauseWindows.invalid) { lines.push(styler.cell(`(pause-windows file invalid: ${pauseWindows.invalid})`, inner, { color: "error" })); return { count: 0, lines }; }
const list = (pauseWindows && pauseWindows.windows) ?? [];
if (list.length === 0) { lines.push(styler.cell("(no pause windows · w to manage)", inner, { color: "dim" })); return { count: 0, lines }; }
const now = Date.now();
for (const w of list) lines.push(pauseRow(w, now, inner, styler));
return { count: list.length, lines };
}
function pauseRow(w: any, now: number, inner: number, styler: any): string {
const until = windowEndAt(w, now); // ms when this window resumes, or null when not active now
const dot = until ? styler.fg("warning", "●") : styler.fg("dim", "○");
const bits = [
`${dot} ${styler.fg("accent", w.scope ?? "-")}`,
styler.fg("text", `${w.from ?? "-"}–${w.to ?? "-"}`) + " " + styler.fg("dim", w.tz ?? "UTC"),
];
if (w.days) bits.push(styler.fg("muted", `[${w.days.join(",")}]`));
if (w.dateFrom || w.dateTo) bits.push(styler.fg("dim", `${w.dateFrom ?? "…"}→${w.dateTo ?? "…"}`));
if (until) bits.push(styler.fg("warning", `resumes in ${humanizeMs(until - now) || "<1m"}`));
return fitLine(bits.join(styler.fg("dim", " ")), inner, styler);
}
/** The interactive RUNS list, colored: cursor, id, target, flow, outcome (✔/⚠/✘), turns, tokens.
* A cursor-following viewport of RUNS_VIEWPORT rows over the (up to 50) records, with `↑/↓ N more` edge
* markers -- the frame stays the same height no matter how many records the read model served. */
function runLines(rows: any[], selected: number, inner: number, styler: any): string[] {
if (!Array.isArray(rows) || rows.length === 0) return [styler.cell("(no runs)", inner, { color: "dim" })];
const { top, count } = runsWindow(rows.length, selected);
const out: string[] = [];
if (top > 0) out.push(styler.cell(`↑ ${top} more`, inner, { color: "dim" }));
for (let i = top; i < top + count; i++) out.push(runRow(rows[i], i === selected, inner, styler));
const below = rows.length - top - count;
if (below > 0) out.push(styler.cell(`↓ ${below} more`, inner, { color: "dim" }));
return out;
}
/** The viewport over the run rows: centered on the cursor, clamped to the ends. A cursor outside the runs
* section (negative `selected`, i.e. still up in the triggers) anchors the window at the top. */
function runsWindow(len: number, selected: number): { top: number; count: number } {
const count = Math.min(len, RUNS_VIEWPORT);
const want = selected >= 0 ? selected - Math.floor(RUNS_VIEWPORT / 2) : 0;
const top = Math.min(Math.max(0, want), len - count);
return { top, count };
}
/**