-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathdashboard.ts
More file actions
2419 lines (2310 loc) · 133 KB
/
Copy pathdashboard.ts
File metadata and controls
2419 lines (2310 loc) · 133 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`; TRIGGER_DETAIL -- one trigger's trust model;
* COSTS (issue #53) -- the read-time cost fold over the run sidecars and declared subscriptions; and
* GRAPH (issue #54) -- the trigger/flow topology, rendered from the same assembled model as
* `/dispatch graph`, refreshed only on entry and on `r` (the enumeration spawns git per folder).
*
* 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";
// The pricing façade is imported HERE and wired into the deps factory alone: dashboard views call
// injected seams (`fetchCosts`/`listPricedModels`/`whatIf`), never the façade, so tests stay fully
// canned and the one worker/pricing coupling sits beside the queue and redis this module already owns.
import * as pricing from "@edgehero/pi-dispatch/pricing";
import { listRuns, readSettingsView, mapSchedulers, readTriggers, readPauseWindows, readStagedPackages, readSubscriptions, scanRunRecords, GRAPH_LIMITS, cronRunStats, joinRunsToTriggers, observedChainEdges, collectGraphInputs } from "./read-model.mjs";
import { renderStatus, renderBudget, renderTriggers, renderSettingsView, renderGraph } from "./render.mjs";
import { buildGraphModel } from "./graph-model.mjs";
import { matchesKey } from "./keys.mjs";
import { box, meter, clip, fmtUsd, makeLineInput } from "./panel.mjs";
import { makeStyler, frame, RULE } from "./style.mjs";
import { foldCosts, whatIfFlow } from "./costs.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;
// COSTS: the spend windows `t` cycles through, and the staleness bound the poll tick refreshes against.
// The fold itself is cheap, but the scan behind fetchCosts reads EVERY run sidecar in the window -- a
// per-second full-directory read is the kind of quiet load a dashboard must not add, so while the view
// is open a poll tick refreshes the fold only once the last fetch is older than this.
const COSTS_WINDOWS = ["7d", "30d", "mtd"];
const COSTS_STALE_MS = 10_000;
// GRAPH: the cursor-following row window, on the RUNS_VIEWPORT/TAIL_VIEWPORT precedent -- a fixed
// bound with no height dependency, so an unknown terminal height changes nothing. The graph REFRESHES
// only on entry and on `r`, never on the poll tick: fetchGraph spawns git per enumerated folder, a
// heavier read than even the costs scan, and topology changes when the operator edits things, not per
// second.
const GRAPH_VIEWPORT = 16;
/** 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,
};
},
/**
* One COSTS read: scan the run sidecars for the window, read the declared subscriptions, and fold
* them at the REAL pricing façade's rates (pinned by its own piAiVersion). A scan error degrades to
* `{ unreachable }` so the view renders it in-frame; the subscriptions file missing or invalid
* degrades to no plans -- the fold still prices what it can. `records` ride along in the result
* because the what-if seam re-folds them per target.
*/
fetchCosts({ windowKey }: any) {
const nowMs = Date.now();
const records = scanRunRecords({ logsDir: paths.logsDir, sinceMs: costsWindowSinceMs(windowKey, nowMs), nowMs });
if (!Array.isArray(records)) return { unreachable: (records as any)?.unreachable ?? "scan failed" };
const subsView: any = readSubscriptions({ subscriptionsPath: paths.subscriptionsPath });
const subscriptions = Array.isArray(subsView?.subscriptions) ? subsView.subscriptions : [];
const fold = foldCosts({ records, subscriptions, pricing, nowMs, piAiPin: pricing.piAiVersion() });
return { fold, records, subscriptions };
},
/**
* One GRAPH read (issue #54): triggers FRESH (OQ-008 -- a cached topology is a stale topology),
* one bounded record scan, the folder/injected enumerations, and the pure fold. Schedulers ride
* in from the caller's snapshot so this seam opens no second queue read. All fs/git access lives
* in the read-model functions this calls; this module still touches nothing itself.
*/
fetchGraph({ schedulers }: any = {}) {
const nowMs = Date.now();
const triggersView: any = readTriggers({ triggersPath: paths.triggersPath });
const triggerList: any[] = Array.isArray(triggersView?.triggers) ? triggersView.triggers : [];
const records: any = scanRunRecords({ logsDir: paths.logsDir, sinceMs: nowMs - GRAPH_LIMITS.windowDays * 24 * 60 * 60 * 1000, nowMs });
const recs: any[] = Array.isArray(records) ? records : [];
return buildGraphModel({
triggers: triggersView,
schedulers: Array.isArray(schedulers) ? schedulers : [],
...collectGraphInputs({ triggers: triggerList }),
cronStats: cronRunStats({ records: recs, schedulerIds: triggerList.filter((t) => t.type === "cron" && typeof t.id === "string").map((t) => t.id) }),
runJoin: joinRunsToTriggers({ records: recs, triggerCount: triggersView?.count }),
chainEdges: observedChainEdges({ records: recs }),
caps: { chainDepthMax: paths.chainDepthMax, chainMaxPerJob: paths.chainMaxPerJob, windowDays: GRAPH_LIMITS.windowDays },
nowMs,
});
},
/** The priced-model catalog for the what-if `/` filter -- the façade stays behind this seam. */
listPricedModels() {
return pricing.listPricedModels();
},
/** One what-if estimate at the real rates, over the records the last fetchCosts served. */
whatIf({ records, flow, target }: any) {
return whatIfFlow({ records, flow, target, pricing });
},
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;
// COSTS (issue #53): the last fetchCosts result and the view's own layers. `data` is whatever the seam
// returned ({ fold, records, subscriptions } or { unreachable }); `fetchedAt` drives the poll-tick
// staleness gate; `table` picks the by-flow or by-model rollup; `whatIf` is the layered estimate state
// (null when closed) -- the flow it targets, the target shortlist and its cursor, and the optional `/`
// filter input over the priced-model catalog. `costsSel` is the table's row cursor (the LIST idiom).
let costs: any = { data: null, windowKey: "mtd", fetchedAt: 0, table: "flow", whatIf: null };
let costsSel = 0;
let costsFetching = false;
// GRAPH (issue #54): the last fetched model, its error, and the view's own cursor/folds. `folded`
// holds folder keys the operator collapsed with Enter; it survives a refresh on purpose -- a refresh
// answers "what changed", not "start over".
let graph: any = { model: null, error: null, fetchedAt: 0, folded: new Set() };
let graphSel = 0;
let graphFetching = 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 });
}
// COSTS piggyback (the policy lives on fetchCostsNow): only while the view is open, and only once
// the last fold has gone stale -- never a full sidecar scan per poll tick.
if (view === "COSTS" && Date.now() - costs.fetchedAt > COSTS_STALE_MS) {
await fetchCostsNow();
}
} 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();
};
// COSTS refresh policy: fetch on view entry and on every windowKey change (both call this directly);
// while the view is open the 1s poll piggybacks a refresh ONLY once the last fetch is older than
// COSTS_STALE_MS (see `refresh`). Errors degrade to an in-frame message, never a throw, and an
// in-flight fetch suppresses the next so a slow scan cannot stack directory reads.
const fetchCostsNow = async () => {
if (typeof deps?.fetchCosts !== "function" || costsFetching || disposed) return;
costsFetching = true;
try {
costs.data = await deps.fetchCosts({ windowKey: costs.windowKey });
} catch (err: any) {
costs.data = { unreachable: err?.message ?? String(err) };
} finally {
costs.fetchedAt = Date.now();
costsFetching = false;
if (costs.whatIf) computeWhatIf(); // fresh records re-price an open estimate
tui?.requestRender?.();
}
};
// GRAPH refresh policy: on entry and on `r`, NEVER on the poll tick -- the strictest of the three
// view policies, because fetchGraph spawns git per enumerated folder. Errors degrade to an in-frame
// message; an in-flight fetch suppresses the next so a slow enumeration cannot stack spawns.
const fetchGraphNow = async () => {
if (typeof deps?.fetchGraph !== "function" || graphFetching || disposed) return;
graphFetching = true;
try {
graph.model = await deps.fetchGraph({ schedulers: snapshot?.schedulers ?? [] });
graph.error = null;
} catch (err: any) {
graph.error = err?.message ?? String(err);
} finally {
graph.fetchedAt = Date.now();
graphFetching = false;
tui?.requestRender?.();
}
};
/** Re-run the injected what-if seam for the current target and stash the result for render(). The
* estimate is computed at key time, not per frame -- render() stays a pure read of component state. */
const computeWhatIf = () => {
const wi = costs.whatIf;
if (!wi) return;
wi.target = wi.targets[wi.index] ?? null;
wi.result =
wi.target !== null && typeof deps?.whatIf === "function"
? deps.whatIf({ records: costs.data?.records ?? [], flow: wi.flow, target: wi.target })
: null;
};
/** Re-filter the priced-model catalog against the `/` input (a `provider/id` substring match). */
const refreshWhatIfMatches = () => {
const wi = costs.whatIf;
if (!wi || !wi.input) return;
const query = wi.input.value().toLowerCase();
const catalog = typeof deps?.listPricedModels === "function" ? deps.listPricedModels() : [];
wi.matches = catalog.filter((m: any) => `${m.provider}/${m.id}`.toLowerCase().includes(query));
};
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);
}
// Same clamp for the COSTS table cursor: a table that shrank on refresh (or the `f` flip to a
// shorter rollup) can never leave the cursor pointing past the end.
if (view === "COSTS" && costsSel > costsTableRows(costs).length - 1) {
costsSel = Math.max(0, costsTableRows(costs).length - 1);
}
// And for the GRAPH cursor: a refresh (or a fold) can shrink the row list under it.
if (view === "GRAPH" && graphSel > graphRows(graph.model, graph.folded).length - 1) {
graphSel = Math.max(0, graphRows(graph.model, graph.folded).length - 1);
}
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,
costs,
costsSel,
costsAvailable: typeof deps?.fetchCosts === "function",
graph,
graphSel,
graphAvailable: typeof deps?.fetchGraph === "function",
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 (the COSTS filter is
// the template): 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 (view === "COSTS") {
const wi = costs.whatIf;
// The `/` filter input is the innermost layer, routed BEFORE every view key: typing "f" or "t"
// into the filter must narrow the model list, not flip the table under the operator's cursor.
if (wi && wi.filter && wi.input) {
if (matchesKey(data, "escape")) {
wi.filter = false;
wi.input = null;
tui?.requestRender?.();
return;
}
if (data === "\r" || data === "\n") {
// Enter applies the top match as the active target: an already-listed pick moves the cycle
// cursor onto it; a new one is spliced in AT the cursor so `w` keeps cycling from there.
const pick = wi.matches?.[0];
if (pick) {
const target = { provider: pick.provider, id: pick.id };
const at = wi.targets.findIndex((t: any) => t.provider === target.provider && t.id === target.id);
if (at >= 0) wi.index = at;
else wi.targets.splice(wi.index, 0, target);
computeWhatIf();
}
wi.filter = false;
wi.input = null;
tui?.requestRender?.();
return;
}
if (matchesKey(data, "backspace")) wi.input.backspace();
else if (matchesKey(data, "left")) wi.input.left();
else if (matchesKey(data, "right")) wi.input.right();
else if (matchesKey(data, "home")) wi.input.home();
else if (matchesKey(data, "end")) wi.input.end();
else if (!data.startsWith("\x1b") && data >= " ") wi.input.insert(data);
else return; // any other control sequence is inert while the filter is up
refreshWhatIfMatches();
tui?.requestRender?.();
return;
}
// Esc pops ONE layer at a time: what-if -> COSTS -> LIST (the filter layer popped above).
if (matchesKey(data, "escape")) {
if (costs.whatIf) costs.whatIf = null;
else view = "LIST";
tui?.requestRender?.();
return;
}
if (matchesKey(data, "up") || matchesKey(data, "down")) {
const step = matchesKey(data, "up") ? -1 : 1;
costsSel = Math.min(Math.max(0, costsTableRows(costs).length - 1), Math.max(0, costsSel + step));
tui?.requestRender?.();
return;
}
if (data === "f" || data === "F") {
costs.table = costs.table === "flow" ? "model" : "flow";
costsSel = 0;
tui?.requestRender?.();
return;
}
if (data === "t" || data === "T") {
costs.windowKey = COSTS_WINDOWS[(COSTS_WINDOWS.indexOf(costs.windowKey) + 1) % COSTS_WINDOWS.length];
void fetchCostsNow(); // a window change is a different scan cutoff -- fetch now, not on the tick
tui?.requestRender?.();
return;
}
if (data === "w" || data === "W") {
// What-if targets FLOWS (whatIfFlow's grain), so on the model table the key is inert.
if (costs.table !== "flow") return;
if (costs.whatIf) {
costs.whatIf.index = (costs.whatIf.index + 1) % costs.whatIf.targets.length;
computeWhatIf();
tui?.requestRender?.();
return;
}
const row = (costs.data?.fold?.byFlow ?? [])[costsSel];
const targets = whatIfTargets(costs.data);
if (!row || targets.length === 0) return;
costs.whatIf = { flow: row.flow, targets, index: 0, filter: false, input: null };
computeWhatIf();
tui?.requestRender?.();
return;
}
if (data === "/") {
if (!costs.whatIf) return; // the filter refines an open what-if; it is not a view of its own
costs.whatIf.filter = true;
costs.whatIf.input = makeLineInput("");
refreshWhatIfMatches();
tui?.requestRender?.();
return;
}
// Everything else -- including q/p/r -- is inert in COSTS; leaving is Esc's job alone.
return;
}
if (view === "GRAPH") {
// Esc pops one layer to LIST; everything else the view does not own is inert (the COSTS rule).
if (matchesKey(data, "escape")) {
view = "LIST";
tui?.requestRender?.();
return;
}
if (matchesKey(data, "up") || matchesKey(data, "down")) {
const step = matchesKey(data, "up") ? -1 : 1;
graphSel = Math.min(Math.max(0, graphRows(graph.model, graph.folded).length - 1), Math.max(0, graphSel + step));
tui?.requestRender?.();
return;
}
if (data === "\r" || data === "\n") {
const row = graphRows(graph.model, graph.folded)[graphSel];
if (!row) return;
if (row.kind === "folder") {
// Enter on a group header folds/unfolds it; the fold set survives a refresh on purpose.
if (graph.folded.has(row.key)) graph.folded.delete(row.key);
else graph.folded.add(row.key);
tui?.requestRender?.();
return;
}
if (row.kind === "gtrigger") {
// The graph's trigger rows reuse the existing drill: the display record comes from the
// snapshot by RAW index (the identity both sides carry), so TRIGGER_DETAIL behaves exactly
// as it does from LIST -- same editor, same delete confirm, same trust model.
const record = (snapshot?.triggers?.triggers ?? []).find((t: any) => t?.index === row.node.index);
if (!record) return;
detailTrigger = { record, index: row.node.index };
pendingDelete = false;
view = "TRIGGER_DETAIL";
tui?.requestRender?.();
return;
}
return;
}
// `r` refreshes the model -- the ONLY re-read path besides entry; the poll tick never does.
if (data === "r" || data === "R") {
void fetchGraphNow();
return;
}
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;
}
// `c` -- the COSTS view (issue #53): the read-time fold over the window's run sidecars and the
// declared subscriptions. Fetch on entry (the poll only piggybacks once the data is stale); the
// window key survives re-entry on purpose -- an operator flipping back is asking the same question.
if (data === "c" || data === "C") {
view = "COSTS";
costsSel = 0;
costs.whatIf = null;
void fetchCostsNow();
tui?.requestRender?.();
return;
}
// `g` -- the GRAPH view (issue #54): the trigger/flow topology from the same assembled model as
// /dispatch graph. Fetch on entry; the fold set survives re-entry (same reason the costs window
// does -- an operator flipping back is asking the same question).
if (data === "g" || data === "G") {
view = "GRAPH";
graphSel = 0;
void fetchGraphNow();
tui?.requestRender?.();
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, costs, costsSel, costsAvailable, graph, graphSel, graphAvailable, 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 (view === "COSTS") {
return renderCosts({ costs, costsSel, costsAvailable, framed, width: Math.trunc(width), styler, availableRows: terminalRows });
}
if (view === "GRAPH") {
return renderGraphView({ graph, graphSel, graphAvailable, framed, width: Math.trunc(width), 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 })) },