@huseynsnmz/pi-board 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,8 @@ import { readFileSync } from "node:fs";
10
10
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
11
  import type { Component, EditorTheme, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
12
12
  import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { readConfig, writeConfig } from "../core/config.mjs";
14
+ import { editExternally } from "./external-editor.ts";
13
15
  import { requestDashboardRender } from "../core/dashboard-render.mjs";
14
16
  import { normalizeGenericStatusText } from "../core/derive.mjs";
15
17
  import { firstSentence, truncate } from "../core/heuristics.mjs";
@@ -28,7 +30,7 @@ import {
28
30
  import { createPrewarmScheduler } from "../core/prewarm-schedule.mjs";
29
31
  import { planDashboardAttach, planPrewarm, replyNotice } from "./dashboard-decisions.mjs";
30
32
  import { ensureCwdStatsSeeded, rankedCwdCandidates, recordCwdLaunch } from "../core/cwd-stats.mjs";
31
- import { filterRows, groupRowsByFolder, rowState, stateGlyph } from "../core/rows.mjs";
33
+ import { filterRows, groupRowsByFolder, groupRowsByDirectory, rowState, stateGlyph } from "../core/rows.mjs";
32
34
  import { loadSessionView } from "../core/session-view.mjs";
33
35
  import { GROUP_LABELS } from "../core/types.mjs";
34
36
  import { buildEvidencePanel } from "./dashboard-evidence.mjs";
@@ -117,6 +119,7 @@ export interface DashboardDeps {
117
119
  service: Service;
118
120
  root: string;
119
121
  defaultCwd: string;
122
+ currentSessionFile?: string | null;
120
123
  availableModels: LaunchModel[];
121
124
  currentModel: LaunchModel | null;
122
125
  currentThinkingLevel: ThinkingLevel;
@@ -134,6 +137,10 @@ export class DashboardComponent implements Component {
134
137
  private filterQuery = "";
135
138
  private pending: PendingConfirm | null = null;
136
139
  private deleteArm: { id: string; at: number } | null = null;
140
+ private exitArmedAt: number | null = null;
141
+ private groupBy: "state" | "directory" = readConfig().groupBy === "directory" ? "directory" : "state";
142
+ private readonly groupOf = new Map<string, string>();
143
+ private readonly neighborsOf = new Map<string, string>();
137
144
  private helpReturnMode: ReturnableMode = "list";
138
145
  private ptyHelpReturnMode: ReturnableMode = "list";
139
146
  private peekId: string | null = null;
@@ -164,6 +171,7 @@ export class DashboardComponent implements Component {
164
171
  private readonly done: (result: DashboardResult) => void,
165
172
  private readonly deps: DashboardDeps,
166
173
  ) {
174
+ stageTheme = theme;
167
175
  this.editor = new CustomEditor(tui, editorTheme(theme), keybindings as never, { paddingX: 0 });
168
176
  this.editor.onChange = (text) => {
169
177
  this.input = this.editor.getExpandedText();
@@ -192,8 +200,18 @@ export class DashboardComponent implements Component {
192
200
  const previousSelected = this.selectedId;
193
201
  const all = this.deps.service.rows();
194
202
  this.rows = this.filterQuery ? filterRows(all, this.filterQuery) : all;
195
- const groups = groupRowsByFolder(this.rows, Date.now());
196
- this.orderedIds = groups.flatMap((g) => g.folders.flatMap((f) => f.rows.map((r) => r.id)));
203
+ this.groupOf.clear();
204
+ this.neighborsOf.clear();
205
+ const place = (group: string, leaf: string, ids: string[]) => {
206
+ for (const id of ids) {
207
+ this.groupOf.set(id, group);
208
+ this.neighborsOf.set(id, `${group}\0${leaf}`);
209
+ }
210
+ return ids;
211
+ };
212
+ this.orderedIds = this.groupBy === "directory"
213
+ ? groupRowsByDirectory(this.rows, Date.now()).flatMap((f) => place(f.key, "", f.rows.map((r) => r.id)))
214
+ : groupRowsByFolder(this.rows, Date.now()).flatMap((g) => g.folders.flatMap((f) => place(g.state, f.key, f.rows.map((r) => r.id))));
197
215
  const visibleIds = new Set(this.orderedIds);
198
216
  this.selectedIds = new Set([...this.selectedIds].filter((id) => visibleIds.has(id)));
199
217
  if (this.orderedIds.length === 0) {
@@ -246,7 +264,7 @@ export class DashboardComponent implements Component {
246
264
  private exitSelectionMode(clear = true): void {
247
265
  if (clear) this.selectedIds.clear();
248
266
  this.mode = "list";
249
- this.notifyInputState("NORMAL mode", "muted");
267
+
250
268
  }
251
269
 
252
270
  private toggleSelectedCurrent(): void {
@@ -283,6 +301,8 @@ export class DashboardComponent implements Component {
283
301
  if (!id || id === this.prewarmedId) return;
284
302
  const row = this.selectedRow();
285
303
  if (!row) return;
304
+ // This session is open behind the board, and a second Pi must not write to its file.
305
+ if (this.deps.currentSessionFile && row.meta.sessionFile === this.deps.currentSessionFile) return;
286
306
  // hostActive claims (starting/alive/stopping) already have a launch — mark
287
307
  // them prewarmed without another service call; a pending claim is not a
288
308
  // failure to retry on every debounce tick (issue #70 A14).
@@ -320,8 +340,7 @@ export class DashboardComponent implements Component {
320
340
  this.handleSelectKey(data);
321
341
  break;
322
342
  case "dispatch":
323
- if (matchesKey(data, Key.ctrl("n"))) return this.openLaunchDialog();
324
- this.handleTextMode(data, () => this.startNow(), () => this.leaveDispatchMode());
343
+ this.handleDispatchKey(data);
325
344
  break;
326
345
  case "filter":
327
346
  this.handleTextMode(data, () => this.toListMode(), () => this.clearFilter(), { live: true });
@@ -359,41 +378,144 @@ export class DashboardComponent implements Component {
359
378
 
360
379
  private handleListKey(data: string): void {
361
380
  if (!matchesKey(data, Key.ctrl("x"))) this.deleteArm = null;
362
- if (matchesKey(data, Key.up) || data === "k") return void this.moveSelection(-1);
363
- if (matchesKey(data, Key.down) || data === "j") return void this.moveSelection(1);
364
- if (matchesKey(data, Key.right) || data === ">" || data === "l") return this.attachSelected();
365
- if (data === "v") return this.openSessionView();
366
- if (data === "e") return this.openEvidenceView();
367
- if (matchesKey(data, Key.enter)) {
368
- if (this.input.trim()) return this.startNow();
369
- return this.attachSelected();
370
- }
381
+ if (!matchesKey(data, Key.ctrl("c"))) this.exitArmedAt = null;
382
+ if (this.handleRowKey(data)) return;
383
+ if (matchesKey(data, Key.right) || matchesKey(data, Key.left) || matchesKey(data, Key.enter)) return this.attachSelected();
371
384
  if (matchesKey(data, Key.space)) return this.openPeek();
372
- if (data === "m") return this.startSelectionMode();
373
- if (data === "/") return this.startFilter();
385
+ if (data === "/" || matchesKey(data, Key.ctrl("f"))) return this.startFilter();
374
386
  if (data === "?") return this.openHelp("list");
375
387
  if (data === "!") return this.openPtyHelp("list");
376
- if (data === "i") return this.startDispatch();
377
- if (matchesKey(data, Key.ctrl("n"))) return this.startDispatch("hello");
378
388
  if (matchesKey(data, Key.ctrl("r"))) return this.startRename();
379
389
  if (matchesKey(data, Key.ctrl("t"))) return this.togglePin();
380
- if (matchesKey(data, Key.ctrl("s"))) return this.stopSelected();
381
- if (data === "d") return this.confirmDone();
382
- if (data === "x") return this.confirmDelete();
383
- if (data === "h") return this.toggleHold();
384
390
  if (matchesKey(data, Key.ctrl("x"))) return this.handleDeleteKey();
385
- if (data === "X") return this.confirmDeleteState();
386
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
387
- if (this.input.length > 0) {
388
- this.setInput("");
389
- return;
390
- }
391
- return this.done({ action: "exit" });
391
+ if (matchesKey(data, Key.ctrl("s"))) return this.toggleGrouping();
392
+ if (matchesKey(data, Key.ctrl("g"))) return void this.openExternalEditor();
393
+ if (matchesKey(data, Key.shift("up"))) return this.moveRow(-1);
394
+ if (matchesKey(data, Key.shift("down"))) return this.moveRow(1);
395
+ if (matchesKey(data, Key.ctrl("a"))) return this.startSelectionMode();
396
+ if (matchesKey(data, Key.ctrl("o"))) return this.openSessionView();
397
+ if (matchesKey(data, Key.ctrl("e"))) return this.openEvidenceView();
398
+ if (matchesKey(data, Key.ctrl("d"))) return this.confirmDone();
399
+ if (matchesKey(data, Key.ctrl("b"))) return this.toggleHold();
400
+ if (matchesKey(data, Key.ctrl("k"))) return this.confirmDeleteState();
401
+ for (let n = 1; n <= 9; n++) {
402
+ if (matchesKey(data, Key.alt(String(n) as never))) return this.attachRow(n - 1);
403
+ }
404
+ if (matchesKey(data, Key.escape)) return this.done({ action: "exit" });
405
+ if (matchesKey(data, Key.ctrl("c"))) {
406
+ const now = Date.now();
407
+ if (this.exitArmedAt !== null && now - this.exitArmedAt <= DELETE_DOUBLE_PRESS_MS) return this.done({ action: "exit" });
408
+ this.exitArmedAt = now;
409
+ this.notifyInputState("Press ctrl+c again to exit", "accent");
410
+ return;
392
411
  }
393
412
  if (isPrintable(data) || isBracketedPaste(data)) {
394
- this.notifyInputState("Press i to enter INSERT mode, then type or paste", "accent");
413
+ this.mode = "dispatch";
414
+ this.handleEditorInput(data);
415
+ }
416
+ }
417
+
418
+ /** The keys that move between rows, in the list and while typing a task. Says if it was one. */
419
+ private handleRowKey(data: string): boolean {
420
+ const rows = this.orderedIds.length;
421
+ const page = Math.max(1, (this.tui.terminal?.rows ?? 24) - 8);
422
+ const at = this.selectedId ? Math.max(0, this.orderedIds.indexOf(this.selectedId)) : 0;
423
+ if (matchesKey(data, Key.up)) this.moveSelection(-1);
424
+ else if (matchesKey(data, Key.down)) this.moveSelection(1);
425
+ else if (matchesKey(data, Key.pageUp)) this.selectIndex(Math.max(0, at - page));
426
+ else if (matchesKey(data, Key.pageDown)) this.selectIndex(Math.min(rows - 1, at + page));
427
+ else if (matchesKey(data, Key.home)) this.selectIndex(0);
428
+ else if (matchesKey(data, Key.end)) this.selectIndex(rows - 1);
429
+ else if (matchesKey(data, Key.alt("up"))) this.jumpGroup(-1);
430
+ else if (matchesKey(data, Key.alt("down"))) this.jumpGroup(1);
431
+ else return false;
432
+ return true;
433
+ }
434
+
435
+ private selectIndex(index: number): void {
436
+ const id = this.orderedIds[index];
437
+ if (!id || id === this.selectedId) return;
438
+ this.selectedId = id;
439
+ this.prewarmScheduler.schedule();
440
+ }
441
+
442
+ private toggleGrouping(): void {
443
+ this.groupBy = this.groupBy === "state" ? "directory" : "state";
444
+ try {
445
+ writeConfig({ groupBy: this.groupBy });
446
+ } catch {
447
+ /* best effort: it still applies until the board closes */
448
+ }
449
+ this.refresh();
450
+ }
451
+
452
+ /** Swaps it with the row next to it, within the same group. */
453
+ private moveRow(direction: 1 | -1): void {
454
+ const id = this.selectedId;
455
+ if (!id) return;
456
+ const neighbor = this.orderedIds[this.orderedIds.indexOf(id) + direction];
457
+ if (!neighbor || this.neighborsOf.get(neighbor) !== this.neighborsOf.get(id)) return;
458
+ const group = this.orderedIds.filter((other) => this.neighborsOf.get(other) === this.neighborsOf.get(id));
459
+ const at = group.indexOf(id);
460
+ [group[at], group[at + direction]] = [group[at + direction]!, group[at]!];
461
+ this.deps.service.setRowOrder?.(group);
462
+ this.refresh();
463
+ }
464
+
465
+ private async openExternalEditor(): Promise<void> {
466
+ const terminal = this.tui as unknown as { stop?: () => void; start?: () => void };
467
+ terminal.stop?.();
468
+ let edited: string | undefined;
469
+ try {
470
+ edited = await editExternally(this.input);
471
+ } finally {
472
+ terminal.start?.();
473
+ this.tui.requestRender(true);
474
+ }
475
+ if (edited === undefined) {
476
+ this.notifyInputState("Set $VISUAL or $EDITOR to write the task in your editor", "warning");
477
+ return;
478
+ }
479
+ this.setInput(edited);
480
+ this.mode = edited ? "dispatch" : "list";
481
+ }
482
+
483
+ /** To the first row of the next group, or of this one when going back. */
484
+ private jumpGroup(direction: 1 | -1): void {
485
+ const stateAt = (index: number) => this.groupOf.get(this.orderedIds[index]);
486
+ let index = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
487
+ const start = stateAt(index);
488
+ if (direction === 1) {
489
+ while (index < this.orderedIds.length && stateAt(index) === start) index++;
490
+ if (index < this.orderedIds.length) this.selectIndex(index);
395
491
  return;
396
492
  }
493
+ if (index > 0 && stateAt(index - 1) !== start) index--;
494
+ const group = stateAt(index);
495
+ while (index > 0 && stateAt(index - 1) === group) index--;
496
+ this.selectIndex(index);
497
+ }
498
+
499
+ private attachRow(index: number): void {
500
+ if (!this.orderedIds[index]) return;
501
+ this.selectIndex(index);
502
+ this.attachSelected();
503
+ }
504
+
505
+ private handleDispatchKey(data: string): void {
506
+ if (matchesKey(data, Key.ctrl("n"))) return this.openLaunchDialog();
507
+ if (matchesKey(data, Key.ctrl("g"))) return void this.openExternalEditor();
508
+ if (matchesKey(data, Key.ctrl("enter"))) return this.startNow(true);
509
+ if (matchesKey(data, Key.enter)) return this.startNow();
510
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
511
+ this.setInput("");
512
+ this.mode = "list";
513
+ return;
514
+ }
515
+ // With several lines these move the cursor.
516
+ if (!this.input.includes("\n") && this.handleRowKey(data)) return;
517
+ this.handleEditorInput(data);
518
+ if (!this.input) this.mode = "list";
397
519
  }
398
520
 
399
521
  private handleSelectKey(data: string): void {
@@ -424,23 +546,20 @@ export class DashboardComponent implements Component {
424
546
  }
425
547
 
426
548
  private handlePeekKey(data: string): void {
427
- if (matchesKey(data, Key.escape)) return void (this.mode = "list");
428
- if (matchesKey(data, Key.right) || data === ">" || data === "l") return this.attachPeek();
429
- if (data === "v") return this.openSessionView();
430
- if (matchesKey(data, Key.up) || data === "k") {
431
- this.peekStep(-1);
432
- return;
433
- }
434
- if (matchesKey(data, Key.down) || data === "j") {
435
- this.peekStep(1);
436
- return;
437
- }
438
- if (matchesKey(data, Key.enter) || data === "r") return this.startReply();
439
- if (data === "e") return this.openEvidenceView();
440
- if (data === "d") return this.confirmDone();
441
- if (data === "h") return this.toggleHold();
442
- if (data === "a") return this.attachPeek();
549
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.space)) return void (this.mode = "list");
550
+ if (matchesKey(data, Key.right) || matchesKey(data, Key.left)) return this.attachPeek();
551
+ if (matchesKey(data, Key.up)) return void this.peekStep(-1);
552
+ if (matchesKey(data, Key.down)) return void this.peekStep(1);
553
+ if (matchesKey(data, Key.enter)) return this.startReply();
554
+ if (matchesKey(data, Key.ctrl("o"))) return this.openSessionView();
555
+ if (matchesKey(data, Key.ctrl("e"))) return this.openEvidenceView();
556
+ if (matchesKey(data, Key.ctrl("d"))) return this.confirmDone();
557
+ if (matchesKey(data, Key.ctrl("b"))) return this.toggleHold();
443
558
  if (data === "!") return this.openPtyHelp("peek");
559
+ if (isPrintable(data) || isBracketedPaste(data)) {
560
+ this.startReply();
561
+ this.handleEditorInput(data);
562
+ }
444
563
  }
445
564
 
446
565
  private handleSessionKey(data: string): void {
@@ -675,10 +794,10 @@ export class DashboardComponent implements Component {
675
794
  }
676
795
 
677
796
  /** In this folder, with this session's model. Ctrl+N is there for anything else. */
678
- private startNow(): void {
797
+ private startNow(attach = false): void {
679
798
  const cwd = this.deps.defaultCwd;
680
799
  const { model, thinking } = this.buildLaunchState(cwd, this.deps.currentModel, this.deps.currentThinkingLevel);
681
- this.submitDispatch({ cwd, model: model ? canonicalModelRef(model) : null, thinkingLevel: thinking });
800
+ this.submitDispatch({ cwd, model: model ? canonicalModelRef(model) : null, thinkingLevel: thinking, attach });
682
801
  }
683
802
 
684
803
  private openLaunchDialog(): void {
@@ -816,17 +935,6 @@ export class DashboardComponent implements Component {
816
935
  this.input = this.editor.getExpandedText();
817
936
  }
818
937
 
819
- private startDispatch(initialText = ""): void {
820
- this.mode = "dispatch";
821
- this.notifyInputState("INSERT mode — dashboard shortcuts paused; / is literal", "success");
822
- if (initialText) this.handleEditorInput(initialText);
823
- }
824
-
825
- private leaveDispatchMode(): void {
826
- this.mode = "list";
827
- this.notifyInputState(this.input.trim() ? "NORMAL mode — draft kept; Enter dispatches" : "NORMAL mode — dashboard shortcuts active", "muted");
828
- }
829
-
830
938
  private setInput(text: string): void {
831
939
  this.input = text;
832
940
  this.editor.setText(text);
@@ -866,7 +974,7 @@ export class DashboardComponent implements Component {
866
974
  this.filterQuery = "";
867
975
  this.refresh();
868
976
  this.mode = "list";
869
- this.notifyInputState("Filter cleared — NORMAL mode", "muted");
977
+ this.notifyInputState("Filter cleared", "muted");
870
978
  }
871
979
 
872
980
  private submitDispatch(launchOpts?: { cwd?: string; model?: string | null; thinkingLevel?: ThinkingLevel | null; attach?: boolean }): void {
@@ -1060,30 +1168,14 @@ export class DashboardComponent implements Component {
1060
1168
  return;
1061
1169
  }
1062
1170
  this.deleteArm = { id: row.meta.id, at: now };
1171
+ if (row.alive || row.hostAlive) {
1172
+ const res = this.deps.service.stop(row.meta.id);
1173
+ this.notifyInputState(res.ok ? `Stopping "${row.meta.name}". Press ctrl+x again to delete it` : (res.error ?? "Stop failed"), "warning");
1174
+ return;
1175
+ }
1063
1176
  this.notifyInputState(`Press ctrl+x again quickly to delete "${row.meta.name}"${isAgentBusy(row) ? " (stops the active run)" : ""}`, "warning");
1064
1177
  }
1065
1178
 
1066
- private confirmDelete(): void {
1067
- const row = this.selectedRow();
1068
- if (!row) return;
1069
- // Confirmation-style delete (issue #150): `x` then `y`, mirroring the
1070
- // `d`-then-`y` flow. The legacy ctrl+x double-press stays as the
1071
- // no-confirm shortcut.
1072
- this.pending = {
1073
- prompt: `Delete "${row.meta.name}"?${isAgentBusy(row) ? " Stops the active run." : ""} Session file is preserved. (y/N)`,
1074
- onYes: () => {
1075
- // archive is async (routes through the view-state coordinator, issue #91);
1076
- // the notice + refresh land when the command settles.
1077
- void Promise.resolve(this.deps.service.archive(row.meta.id)).then((res) => {
1078
- if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
1079
- else this.notice(`Deleted "${row.meta.name}"`, "info");
1080
- this.refresh();
1081
- });
1082
- },
1083
- };
1084
- this.mode = "confirm";
1085
- }
1086
-
1087
1179
  private confirmDeleteState(): void {
1088
1180
  const row = this.selectedRow();
1089
1181
  if (!row) return;
@@ -1134,8 +1226,7 @@ export class DashboardComponent implements Component {
1134
1226
  this.peekId = this.peekId ?? this.selectedId;
1135
1227
  this.setInput("");
1136
1228
  this.mode = "reply";
1137
- this.notifyInputState("REPLY INSERT mode — arrows edit; Esc returns to peek", "success");
1138
- }
1229
+ }
1139
1230
 
1140
1231
  private openSessionView(): void {
1141
1232
  if (!this.selectedId) return;
@@ -1308,84 +1399,54 @@ export class DashboardComponent implements Component {
1308
1399
  return renderAgentboardHeader(width, this.theme, counts, ptyHealth, this.filterQuery);
1309
1400
  }
1310
1401
 
1402
+ /** Framed as pi-frame frames its editor: what it is on the top edge, the keys on the bottom. */
1311
1403
  private renderFooter(width: number): string[] {
1312
1404
  const t = this.theme;
1313
- const modeColor = this.modeColor();
1314
- const lines = [t.fg(modeColor, "─".repeat(width))];
1315
- if (this.mode === "list" || this.mode === "dispatch" || this.mode === "select") {
1316
- lines.push(...this.taskInputLines(width));
1317
- lines.push("");
1318
- lines.push(clip(this.listHints(), width));
1319
- } else if (this.mode === "filter") {
1320
- lines.push(...this.renderEditorInputLines(width, {
1321
- prompt: `${this.modeBadge("FILTER", "warning")} ${t.fg("warning", "› ")}`,
1322
- continuation: t.fg("warning", "│ "),
1323
- editing: true,
1324
- placeholder: "",
1325
- }));
1326
- lines.push("");
1327
- lines.push(clip(this.hintLine("FILTER", "warning", ["enter apply", "esc clear"]), width));
1328
- } else if (this.mode === "rename") {
1329
- lines.push(...this.renderEditorInputLines(width, {
1330
- prompt: `${this.modeBadge("RENAME", "accent")} ${t.fg("accent", "› ")}`,
1331
- continuation: t.fg("accent", "│ "),
1332
- editing: true,
1333
- placeholder: "",
1334
- }));
1335
- lines.push("");
1336
- lines.push(clip(this.hintLine("RENAME", "accent", ["enter save", "esc cancel"]), width));
1337
- } else if (this.mode === "confirm" && this.pending) {
1338
- lines.push(...renderFlashBanner({ text: this.pending.prompt, level: "warn" }, width));
1405
+ const color = this.modeColor();
1406
+ const inner = Math.max(1, width - 4);
1407
+ if (this.mode === "confirm" && this.pending) {
1408
+ return renderFrame(t, "warning", width, { title: t.fg("warning", "confirm"), hints: "" }, [t.fg("warning", this.pending.prompt)]);
1409
+ }
1410
+ let title = t.fg("muted", "new session");
1411
+ let hints = this.listHints();
1412
+ let body: string[];
1413
+ if (this.mode === "filter" || this.mode === "rename") {
1414
+ title = t.fg(color as never, t.bold(this.mode));
1415
+ hints = this.mode === "filter" ? ["enter apply", "esc clear"] : ["enter save", "esc cancel"];
1416
+ body = this.renderEditorInputLines(inner, { prompt: t.fg(color as never, "› "), continuation: "", editing: true, placeholder: "" });
1417
+ } else if (this.mode === "list" || this.mode === "dispatch" || this.mode === "select") {
1418
+ if (this.mode === "select") title = t.fg(color as never, t.bold("select"));
1419
+ body = this.taskInputLines(inner);
1339
1420
  } else {
1340
- lines.push(clip(this.listHints(), width));
1421
+ body = [];
1341
1422
  }
1423
+ const lines = renderFrame(t, color, width, { title, hints: t.fg("dim", hints.join(" ")) }, body);
1342
1424
  const notice = this.currentInputNotice();
1343
- if (notice) lines.push(clip(t.fg(notice.color, `ⓘ ${notice.text}`), width));
1344
- lines.push(t.fg(modeColor, "─".repeat(width)));
1425
+ if (notice) lines.push(clip(` ${t.fg(notice.color, notice.text)}`, width));
1345
1426
  return lines;
1346
1427
  }
1347
1428
 
1348
- private listHints(): string {
1429
+ private listHints(): string[] {
1349
1430
  const selected = this.selectedRow();
1350
1431
  const live = selected ? selected.hostAlive && isAgentBusy(selected) : false;
1351
1432
  const unread = this.unreadCount();
1352
- if (this.mode === "dispatch") {
1353
- return this.hintLine("INSERT", "success", ["esc normal", "enter launch", "shift+enter newline", "←/→ edit", "ctrl/alt+←/→ word"]);
1354
- }
1433
+ const dots = unread > 0 ? [`•${unread} unread`] : [];
1434
+ if (this.mode === "dispatch") return ["enter start", "ctrl+enter start & attach", "ctrl+n options", "esc clear"];
1355
1435
  if (this.mode === "select") {
1356
- return this.hintLine("SELECT", "accent", [
1357
- `${this.selectionCount()} selected`,
1358
- ...(unread > 0 ? [`•${unread} unread`] : []),
1359
- "↑↓/j/k move",
1360
- "space toggle",
1361
- "a all visible",
1362
- "u clear",
1363
- "d done",
1364
- "ctrl+x delete",
1365
- "m/esc cancel",
1366
- "? help",
1367
- "! pty",
1368
- ]);
1369
- }
1370
- const primary = this.input.trim() ? "enter launch" : live ? "enter attach live" : "enter resume";
1371
- const hints = ["i insert", primary, "→/l attach", "m multi-select", ...(unread > 0 ? [`•${unread} unread`] : []), "d done", "h hold", "space peek", "v transcript", "e evidence", "ctrl+n new session", "ctrl+r rename", "x delete (y/N)", "ctrl+x x2 quick", "X delete state", "/ filter", "! pty", "? help"];
1372
- if (this.input.trim()) hints.splice(1, 0, "esc clear");
1373
- return this.hintLine("NORMAL", "muted", hints);
1436
+ return [`${this.selectionCount()} selected`, ...dots, "space toggle", "a all", "u clear", "d done", "ctrl+x delete", "esc cancel"];
1437
+ }
1438
+ return [selected ? (live ? "enter attach live" : "enter resume") : "type to start", "space peek", "/ filter", "ctrl+x stop", "? keys", ...dots];
1374
1439
  }
1375
1440
 
1376
1441
  private hintLine(label: string, color: InputNoticeColor, hints: string[]): string {
1377
- return `${this.modeBadge(label, color)} ${this.theme.fg("dim", hints.join(" · "))}`;
1442
+ return `${this.theme.fg(color, this.theme.bold(label))} ${this.theme.fg("dim", hints.join(" "))}`;
1378
1443
  }
1379
1444
 
1380
- private modeBadge(label: string, color: InputNoticeColor): string {
1381
- return this.theme.fg(color, this.theme.bold(`[${label}]`));
1382
- }
1383
-
1384
- private modeColor(): InputNoticeColor {
1445
+ private modeColor(): string {
1385
1446
  switch (this.mode) {
1386
1447
  case "dispatch":
1387
1448
  case "reply":
1388
- return "success";
1449
+ return "accent";
1389
1450
  case "filter":
1390
1451
  case "confirm":
1391
1452
  return "warning";
@@ -1393,17 +1454,17 @@ export class DashboardComponent implements Component {
1393
1454
  case "select":
1394
1455
  return "accent";
1395
1456
  default:
1396
- return "dim";
1457
+ return "borderMuted";
1397
1458
  }
1398
1459
  }
1399
1460
 
1400
1461
  private taskInputLines(width: number): string[] {
1401
1462
  const editing = this.mode === "dispatch";
1402
1463
  return this.renderEditorInputLines(width, {
1403
- prompt: editing ? this.theme.fg("success", "┃ ") : this.theme.fg("muted", "› "),
1404
- continuation: editing ? this.theme.fg("success", "┃ ") : this.theme.fg("dim", "│ "),
1464
+ prompt: this.theme.fg(editing ? "accent" : "muted", "› "),
1465
+ continuation: "",
1405
1466
  editing,
1406
- placeholder: editing ? "" : "press i to write a prompt for a new session",
1467
+ placeholder: editing ? "" : "type a task to start a session",
1407
1468
  });
1408
1469
  }
1409
1470
 
@@ -1418,7 +1479,8 @@ export class DashboardComponent implements Component {
1418
1479
  const contentWidth = Math.max(1, width - visibleWidth(opts.prompt));
1419
1480
  return [clip(`${opts.prompt}${clip(this.theme.fg("muted", opts.placeholder ?? ""), contentWidth)}`, width)];
1420
1481
  }
1421
- const rendered = this.editor.render(width);
1482
+ // Its first and last lines are its own rules, and the frame already draws those.
1483
+ const rendered = this.editor.render(width).slice(1, -1);
1422
1484
  return opts.editing ? rendered : rendered.map((line) => stripInactiveEditorCursor(line));
1423
1485
  }
1424
1486
 
@@ -1428,6 +1490,7 @@ export class DashboardComponent implements Component {
1428
1490
  return [t.fg("muted", this.filterQuery ? " No sessions match the filter." : " No background sessions yet. Type below and press Enter.")];
1429
1491
  }
1430
1492
  const now = Date.now();
1493
+ if (this.groupBy === "directory") return this.renderDirectories(width, now);
1431
1494
  const groups = groupRowsByFolder(this.rows, now);
1432
1495
  const out: string[] = [];
1433
1496
  for (const g of groups) {
@@ -1444,6 +1507,23 @@ export class DashboardComponent implements Component {
1444
1507
  return out;
1445
1508
  }
1446
1509
 
1510
+ private renderDirectories(width: number, now: number): string[] {
1511
+ const t = this.theme;
1512
+ const out: string[] = [];
1513
+ for (const folder of groupRowsByDirectory(this.rows, now)) {
1514
+ if (out.length > 0) out.push("");
1515
+ const title = t.fg("accent", t.bold(`▾ ${folder.name}`));
1516
+ const count = t.fg("dim", ` ${folder.rows.length}`);
1517
+ const rule = Math.max(0, width - visibleWidth(`▾ ${folder.name} ${folder.rows.length}`) - 2);
1518
+ out.push(clip(`${title}${count} ${t.fg("borderMuted", "─".repeat(rule))}`, width));
1519
+ for (const rv of folder.rows) {
1520
+ const summary = `${GROUP_LABELS[rv.state]} · ${rv.summary}`;
1521
+ out.push(this.renderRow({ ...rv, summary }, width, { showFolder: false, indent: 2 }));
1522
+ }
1523
+ }
1524
+ return out;
1525
+ }
1526
+
1447
1527
  private renderStageHeader(state: keyof typeof GROUP_LABELS, label: string, count: number, width: number): string {
1448
1528
  const t = this.theme;
1449
1529
  const glyph = stateGlyph(state, state === "working");
@@ -1540,17 +1620,17 @@ export class DashboardComponent implements Component {
1540
1620
  if (this.mode === "reply") {
1541
1621
  out.push(
1542
1622
  ...this.renderEditorInputLines(width, {
1543
- prompt: `${this.modeBadge("REPLY", "success")} ${t.fg("success", "› ")}`,
1623
+ prompt: t.fg("accent", "› "),
1544
1624
  continuation: t.fg("success", "reply┃ "),
1545
1625
  editing: true,
1546
1626
  placeholder: "",
1547
1627
  }),
1548
1628
  );
1549
- out.push(clip(this.hintLine("REPLY INSERT", "success", ["enter send", "shift+enter newline", "esc back"]), width));
1629
+ out.push(clip(this.hintLine("reply", "accent", ["enter send", "shift+enter newline", "esc back"]), width));
1550
1630
  const notice = this.currentInputNotice();
1551
1631
  if (notice) out.push(clip(t.fg(notice.color, `ⓘ ${notice.text}`), width));
1552
1632
  } else {
1553
- out.push(clip(t.fg("dim", "↑↓/j/k prev/next · →/l attach · d done · v transcript · e evidence · r reply · esc back"), width));
1633
+ out.push(clip(t.fg("dim", "type to reply ↑↓ prev/next ←/→ attach ctrl+d done ctrl+o transcript ctrl+e evidence esc back"), width));
1554
1634
  }
1555
1635
  return out;
1556
1636
  }
@@ -1684,8 +1764,8 @@ export class DashboardComponent implements Component {
1684
1764
  private renderLaunchButton(width: number, selected: boolean): string {
1685
1765
  const label = ` ${this.launch?.action === "attach" ? "Start & attach" : "Start session"} `;
1686
1766
  const button = selected
1687
- ? `${ansiBg(56, 189, 248)}${ansiFg(15, 23, 42, this.theme.bold(label))}\x1b[49m\x1b[39m`
1688
- : `${ansiBg(30, 41, 59)}${ansiFg(226, 232, 240, label)}\x1b[49m\x1b[39m`;
1767
+ ? this.theme.bg("selectedBg", this.theme.fg("accent", this.theme.bold(label)))
1768
+ : this.theme.fg("muted", label);
1689
1769
  const pad = Math.max(0, width - visibleWidth(label));
1690
1770
  return `${" ".repeat(pad)}${button}`;
1691
1771
  }
@@ -1708,35 +1788,41 @@ export class DashboardComponent implements Component {
1708
1788
  private renderHelp(width: number): string[] {
1709
1789
  const t = this.theme;
1710
1790
  const rows: Array<[string, string]> = [
1711
- ["normal", "Dashboard owns keys; i enters insert mode"],
1712
- ["insert", "Editor owns text keys; / is literal, arrows move cursor"],
1713
- ["i", "Enter insert/compose mode; then / is literal for slash commands"],
1714
- ["m", "Enter multi-select mode from the board list"],
1715
- ["enter", "Normal/insert with draft: open start-session dialog; empty: attach/resume"],
1716
- ["shift+enter", "Insert a newline while in insert/reply"],
1717
- ["esc", "Insert → normal; Normal with draft clears; empty quits standalone dashboard"],
1718
- ["↑/↓ or j/k", "Normal: move selection; Insert: move cursor"],
1719
- ["ctrl/alt+←/→", "Jump by word in insert mode"],
1720
- ["→ / > / l", "Attach to the selected real Pi session"],
1721
- ["d", "Confirm and mark selected inactive session done"],
1722
- ["h", "Hold / unhold the selected inactive session (on-hold state)"],
1723
- ["space", "Peek when input is empty; in multi-select: toggle current row"],
1724
- ["e", "Open evidence / diagnostics panel for selected session"],
1725
- ["/", "Filter in normal mode; use i then / for slash commands"],
1726
- ["?", "Open the key help overlay (list and multi-select)"],
1727
- ["!", "Open node-pty diagnostics and fix steps"],
1728
- ["ctrl+n", "While typing a task: choose its folder, model and thinking first"],
1729
- ["ctrl+r/t/s", "Rename · pin · stop selected"],
1730
- ["x", "Delete selected session (y/N confirm)"],
1731
- ["ctrl+x x2", "Delete selected session (quick double-press, no confirm)"],
1732
- ["X", "Delete all inactive sessions in selected state"],
1733
- ["v", "Open read-only transcript view"],
1734
- ["In select", "↑↓/j/k move · space toggle · a all · u clear · d done · ctrl+x delete · m/esc cancel"],
1735
- ["In peek", "→/l attach · v transcript · e evidence · r reply · ↑↓/j/k adjacent"],
1736
- ["In session", "← back · ↑↓/j/k scroll · e evidence · enter attach · r reply"],
1737
- ["In evidence", "← back · ↑↓/j/k scroll · →/l attach · r reply · v transcript · x clear diag"],
1791
+ ["type", "Start writing a task, with no key first"],
1792
+ ["enter", "With a task: start it. Empty: attach to the selected session"],
1793
+ ["ctrl+enter", "Start the task and attach to it"],
1794
+ ["shift+enter", "New line (ctrl+j too)"],
1795
+ ["ctrl+n", "While typing: choose the folder, model and thinking first"],
1796
+ ["← / →", "Attach to the selected session"],
1797
+ ["↑ / ↓", "Move between rows"],
1798
+ ["pgup / pgdn", "Move by a screenful"],
1799
+ ["home / end", "First or last row"],
1800
+ ["alt+↑ / alt+↓", "Previous or next group"],
1801
+ ["alt+1..9", "Attach to that row"],
1802
+ ["space", "Open or close the peek panel"],
1803
+ ["/ or ctrl+f", "Filter the list"],
1804
+ ["ctrl+s", "Group by state or by directory"],
1805
+ ["shift+↑ / ↓", "Move the row up or down its group"],
1806
+ ["ctrl+g", "Write the task in $VISUAL or $EDITOR"],
1807
+ ["ctrl+t", "Pin or unpin"],
1808
+ ["ctrl+r", "Rename"],
1809
+ ["ctrl+x", "Stop the session; press again within 2s to delete it"],
1810
+ ["ctrl+d", "Mark done"],
1811
+ ["ctrl+b", "Hold or unhold"],
1812
+ ["ctrl+a", "Select several rows"],
1813
+ ["ctrl+o", "Read-only transcript"],
1814
+ ["ctrl+e", "Evidence and diagnostics"],
1815
+ ["ctrl+k", "Delete every inactive session in this state"],
1816
+ ["esc", "Close, clear the input, or leave the board"],
1817
+ ["ctrl+c", "Clear the input; press twice to leave"],
1818
+ ["!", "node-pty diagnostics"],
1819
+ ["?", "This help"],
1820
+ ["In select", "↑↓ move · space toggle · a all · u clear · d done · ctrl+x delete · esc cancel"],
1821
+ ["In peek", "type to reply · ←/→ attach · ↑↓ adjacent · ctrl+o transcript · ctrl+e evidence"],
1822
+ ["In session", "← back · ↑↓ scroll · e evidence · enter attach · r reply"],
1823
+ ["In evidence", "← back · ↑↓ scroll · → attach · r reply · v transcript · x clear diag"],
1738
1824
  ];
1739
- const dialog = [t.fg("accent", t.bold("Hotkeys / keybindings")), ""];
1825
+ const dialog = [t.fg("accent", t.bold("Keys")), ""];
1740
1826
  for (const [k, v] of rows) dialog.push(` ${t.fg("accent", k.padEnd(14))} ${t.fg("muted", v)}`);
1741
1827
  dialog.push("");
1742
1828
  dialog.push(t.fg("dim", ` press any key to return to ${this.helpReturnMode}`));
@@ -1858,9 +1944,9 @@ function renderCenteredBox(lines: string[], width: number, height: number, theme
1858
1944
  const boxWidth = Math.min(width, innerWidth + 4);
1859
1945
  const left = Math.max(0, Math.floor((width - boxWidth) / 2));
1860
1946
  const box = [
1861
- `${theme.fg("borderMuted", "┌")}${theme.fg("borderMuted", "─".repeat(Math.max(0, boxWidth - 2)))}${theme.fg("borderMuted", "┐")}`,
1947
+ `${theme.fg("borderMuted", "╭")}${theme.fg("borderMuted", "─".repeat(Math.max(0, boxWidth - 2)))}${theme.fg("borderMuted", "╮")}`,
1862
1948
  ...lines.map((line) => `${theme.fg("borderMuted", "│")} ${padTo(clip(line, boxWidth - 4), boxWidth - 4)} ${theme.fg("borderMuted", "│")}`),
1863
- `${theme.fg("borderMuted", "└")}${theme.fg("borderMuted", "─".repeat(Math.max(0, boxWidth - 2)))}${theme.fg("borderMuted", "┘")}`,
1949
+ `${theme.fg("borderMuted", "╰")}${theme.fg("borderMuted", "─".repeat(Math.max(0, boxWidth - 2)))}${theme.fg("borderMuted", "╯")}`,
1864
1950
  ];
1865
1951
  const top = Math.max(0, Math.floor((height - box.length) / 2) - 1);
1866
1952
  const out: string[] = [];
@@ -1869,7 +1955,7 @@ function renderCenteredBox(lines: string[], width: number, height: number, theme
1869
1955
  return out;
1870
1956
  }
1871
1957
 
1872
- const DELETE_DOUBLE_PRESS_MS = 500;
1958
+ const DELETE_DOUBLE_PRESS_MS = 2_000;
1873
1959
 
1874
1960
  const AGENTBOARD_VERSION = readAgentBoardVersion();
1875
1961
  function readAgentBoardVersion(): string {
@@ -1882,60 +1968,48 @@ function readAgentBoardVersion(): string {
1882
1968
  }
1883
1969
  }
1884
1970
  // ANSI rendering of /Users/rutvik/Downloads/pi-logo-on-dark.svg.
1885
- // The SVG is a 4x4 grid mark: P-shaped left form plus a separate lower-right stem.
1886
- const PI_ICON = [
1887
- " ▐▛███▜▌",
1888
- "▝▜█████▛▘",
1889
- ] as const;
1890
- const PI_ICON_WIDTH = Math.max(...PI_ICON.map((line) => visibleWidth(line)));
1891
- const HEADER_LEFT_PADDING = 2;
1892
- const HEADER_TEXT_GAP = 4;
1893
- const HEADER_TOP_PADDING = 0;
1894
- const HEADER_BOTTOM_PADDING = 1;
1895
- const AGENTBOARD_HEADER_MIN_WIDTH = HEADER_LEFT_PADDING + PI_ICON_WIDTH + HEADER_TEXT_GAP + 24;
1896
-
1897
1971
  type HeaderCounts = { needs: number; working: number; completed: number; unread: number };
1898
1972
 
1899
- function renderAgentboardHeader(
1900
- width: number,
1973
+ /** Corners and edges as pi-frame draws them, with text set into the top and bottom edges. */
1974
+ function renderFrame(
1901
1975
  theme: ThemeLike,
1902
- counts: HeaderCounts,
1903
- ptyHealth: PtyHealth,
1904
- filterQuery: string,
1976
+ color: string,
1977
+ width: number,
1978
+ edges: { title?: string; right?: string; hints?: string },
1979
+ body: string[],
1905
1980
  ): string[] {
1906
- if (width < AGENTBOARD_HEADER_MIN_WIDTH) {
1907
- const raw = [
1908
- ...blankLines(HEADER_TOP_PADDING),
1909
- clip(`${" ".repeat(HEADER_LEFT_PADDING)}${theme.fg("accent", "◉")} ${theme.fg("accent", theme.bold("AgentBoard"))} ${theme.fg("muted", AGENTBOARD_VERSION)} ${theme.fg("dim", "·")} ${renderPtyHealth(theme, ptyHealth)}`, width),
1910
- clip(headerStageSummary(theme, counts, filterQuery, true), width),
1911
- ...blankLines(HEADER_BOTTOM_PADDING),
1912
- ];
1913
- return raw.map((line) => headerBgLine(line, width));
1914
- }
1915
-
1916
- const textRows = headerTextRows(theme, counts, ptyHealth, filterQuery);
1917
- const textStart = Math.max(0, Math.round((PI_ICON.length - textRows.length) / 2));
1918
- const raw = blankLines(HEADER_TOP_PADDING);
1919
- raw.push(
1920
- ...PI_ICON.map((iconLine, i) =>
1921
- clip(`${" ".repeat(HEADER_LEFT_PADDING)}${renderPiIconLine(theme, iconLine)}${" ".repeat(HEADER_TEXT_GAP)}${textRows[i - textStart] ?? ""}`, width),
1922
- ),
1923
- );
1924
- raw.push(...blankLines(HEADER_BOTTOM_PADDING));
1925
- return raw.map((line) => headerBgLine(line, width));
1981
+ const paint = (text: string) => theme.fg(color as never, text);
1982
+ const edge = (left: string, right: string, text: string, side: string) => {
1983
+ const set = text ? ` ${text} ` : "";
1984
+ const end = side ? ` ${side} ${paint("─")}` : "";
1985
+ const room = width - 3 - visibleWidth(end);
1986
+ const shown = visibleWidth(set) > room ? clip(set, Math.max(0, room)) : set;
1987
+ return `${paint(`${left}─`)}${shown}${paint("─".repeat(Math.max(0, room - visibleWidth(shown))))}${end}${paint(right)}`;
1988
+ };
1989
+ const inner = Math.max(1, width - 4);
1990
+ return [
1991
+ edge("╭", "╮", edges.title ?? "", edges.right ?? ""),
1992
+ ...body.map((line) => `${paint("│")} ${padTo(clip(line, inner), inner)} ${paint("│")}`),
1993
+ edge("╰", "╯", edges.hints ?? "", ""),
1994
+ ];
1926
1995
  }
1927
1996
 
1928
- function headerTextRows(
1997
+ function renderAgentboardHeader(
1998
+ width: number,
1929
1999
  theme: ThemeLike,
1930
2000
  counts: HeaderCounts,
1931
2001
  ptyHealth: PtyHealth,
1932
2002
  filterQuery: string,
1933
2003
  ): string[] {
1934
- const title = `${theme.fg("accent", theme.bold("AgentBoard"))} ${theme.fg("muted", AGENTBOARD_VERSION)} ${theme.fg("dim", "·")} ${renderPtyHealth(theme, ptyHealth)}`;
1935
- return [
1936
- title,
1937
- headerStageSummary(theme, counts, filterQuery, false),
1938
- ];
2004
+ const title = `${theme.fg("accent", theme.bold("π-board"))} ${theme.fg("muted", AGENTBOARD_VERSION.replace(/^v/, ""))}`;
2005
+ const narrow = width < 60;
2006
+ return renderFrame(
2007
+ theme,
2008
+ "borderMuted",
2009
+ width,
2010
+ { title, right: narrow ? "" : renderPtyHealth(theme, ptyHealth) },
2011
+ [headerStageSummary(theme, counts, filterQuery, narrow)],
2012
+ );
1939
2013
  }
1940
2014
 
1941
2015
  function headerStageSummary(theme: ThemeLike, counts: HeaderCounts, filterQuery: string, compact: boolean): string {
@@ -1986,20 +2060,24 @@ function compactPtyReason(reason: string): string {
1986
2060
  return truncate(first.length >= 12 ? first : cleaned, 44);
1987
2061
  }
1988
2062
 
1989
- const STAGE_RGB = {
1990
- queued: [148, 163, 184],
1991
- working: [56, 189, 248],
1992
- needs_input: [245, 158, 11],
1993
- idle: [129, 140, 248],
1994
- holding: [251, 191, 36],
1995
- completed: [34, 197, 94],
1996
- failed: [248, 113, 113],
1997
- stopped: [100, 116, 139],
1998
- } as const satisfies Record<keyof typeof GROUP_LABELS, readonly [number, number, number]>;
2063
+ /** From the Pi theme, as pi-frame's colors are. */
2064
+ const STAGE_TOKENS: Record<keyof typeof GROUP_LABELS, string> = {
2065
+ queued: "muted",
2066
+ working: "accent",
2067
+ needs_input: "warning",
2068
+ idle: "syntaxType",
2069
+ holding: "syntaxNumber",
2070
+ completed: "success",
2071
+ failed: "error",
2072
+ stopped: "dim",
2073
+ };
2074
+
2075
+ /** Set by the component: stageFg is called from places that aren't handed a theme. */
2076
+ let stageTheme: ThemeLike | null = null;
2077
+
1999
2078
 
2000
2079
  function stageFg(state: keyof typeof GROUP_LABELS, text: string): string {
2001
- const [r, g, b] = STAGE_RGB[state];
2002
- return ansiFg(r, g, b, text);
2080
+ return stageTheme ? stageTheme.fg(STAGE_TOKENS[state] as never, text) : text;
2003
2081
  }
2004
2082
 
2005
2083
  /**
@@ -2015,28 +2093,6 @@ export function applyRowHighlight(
2015
2093
  return selected ? theme.bg("selectedBg", text) : text;
2016
2094
  }
2017
2095
 
2018
- function renderPiIconLine(theme: ThemeLike, line: string): string {
2019
- return theme.fg("accent", line);
2020
- }
2021
-
2022
- function ansiFg(r: number, g: number, b: number, text: string): string {
2023
- return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
2024
- }
2025
-
2026
- function ansiBg(r: number, g: number, b: number): string {
2027
- return `\x1b[48;2;${r};${g};${b}m`;
2028
- }
2029
-
2030
- function headerBgLine(content: string, width: number): string {
2031
- const clipped = clip(content, width);
2032
- const rest = Math.max(0, width - visibleWidth(clipped));
2033
- return `${clipped}${" ".repeat(rest)}`;
2034
- }
2035
-
2036
- function blankLines(count: number): string[] {
2037
- return Array.from({ length: count }, () => "");
2038
- }
2039
-
2040
2096
  function isPrintable(data: string): boolean {
2041
2097
  if (data.length === 0) return false;
2042
2098
  // Accept multi-char paste of printable text; reject control/escape sequences.
@@ -2115,27 +2171,8 @@ function editorTheme(theme: ThemeLike): EditorTheme {
2115
2171
  }
2116
2172
 
2117
2173
  function renderFlashBanner(flash: { text: string; level: FlashLevel }, width: number, opts: { bottomGap?: boolean } = {}): string[] {
2118
- const style = flashStyle(flash.level);
2119
- const contentWidth = Math.max(1, width - 1);
2120
- const content = clip(` ${style.icon} ${flash.text} `, contentWidth);
2121
- const line = clip(`${ansiFg(...style.accent, "▌")}${ansiBg(...style.bg)}${ansiFg(...style.fg, content)}\x1b[49m`, width);
2174
+ const [icon, token] = flash.level === "warn" ? ["!", "warning"] : flash.level === "error" ? ["×", "error"] : ["i", "accent"];
2175
+ const text = ` ${icon} ${flash.text}`;
2176
+ const line = clip(stageTheme ? stageTheme.fg(token as never, text) : text, width);
2122
2177
  return opts.bottomGap ? [line, ""] : [line];
2123
2178
  }
2124
-
2125
- type FlashStyle = {
2126
- icon: string;
2127
- accent: readonly [number, number, number];
2128
- bg: readonly [number, number, number];
2129
- fg: readonly [number, number, number];
2130
- };
2131
-
2132
- function flashStyle(level: FlashLevel): FlashStyle {
2133
- switch (level) {
2134
- case "warn":
2135
- return { icon: "!", accent: [245, 158, 11], bg: [39, 31, 14], fg: [253, 230, 138] };
2136
- case "error":
2137
- return { icon: "×", accent: [248, 113, 113], bg: [45, 18, 23], fg: [254, 202, 202] };
2138
- default:
2139
- return { icon: "i", accent: [56, 189, 248], bg: [8, 31, 49], fg: [186, 230, 253] };
2140
- }
2141
- }