@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.
- package/CHANGELOG.md +17 -0
- package/README.md +97 -88
- package/package.json +1 -1
- package/runner/title-runner.mjs +1 -1
- package/src/commands/agent-board.ts +4 -1
- package/src/commands/bg.ts +28 -5
- package/src/commands/board-model.ts +21 -0
- package/src/core/config.mjs +77 -0
- package/src/core/load-config.mjs +4 -0
- package/src/core/rows.mjs +20 -0
- package/src/index.ts +9 -0
- package/src/runtime/service.mjs +28 -1
- package/src/ui/dashboard.ts +319 -282
- package/src/ui/double-left.ts +7 -17
- package/src/ui/external-editor.ts +25 -0
- package/src/ui/focus.ts +26 -0
- package/src/ui/waiting.ts +25 -0
package/src/ui/dashboard.ts
CHANGED
|
@@ -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
|
-
|
|
196
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
363
|
-
if (
|
|
364
|
-
if (matchesKey(data, Key.right) || data
|
|
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 === "
|
|
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
|
|
386
|
-
if (matchesKey(data, Key.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
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
|
|
429
|
-
if (data
|
|
430
|
-
if (matchesKey(data, Key.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (matchesKey(data, Key.
|
|
435
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1314
|
-
const
|
|
1315
|
-
if (this.mode === "
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1353
|
-
|
|
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.
|
|
1357
|
-
|
|
1358
|
-
|
|
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.
|
|
1442
|
+
return `${this.theme.fg(color, this.theme.bold(label))} ${this.theme.fg("dim", hints.join(" "))}`;
|
|
1378
1443
|
}
|
|
1379
1444
|
|
|
1380
|
-
private
|
|
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 "
|
|
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 "
|
|
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:
|
|
1404
|
-
continuation:
|
|
1464
|
+
prompt: this.theme.fg(editing ? "accent" : "muted", "› "),
|
|
1465
|
+
continuation: "",
|
|
1405
1466
|
editing,
|
|
1406
|
-
placeholder: editing ? "" : "
|
|
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
|
-
|
|
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:
|
|
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("
|
|
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", "
|
|
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
|
-
?
|
|
1688
|
-
:
|
|
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
|
-
["
|
|
1712
|
-
["
|
|
1713
|
-
["
|
|
1714
|
-
["
|
|
1715
|
-
["
|
|
1716
|
-
["
|
|
1717
|
-
["
|
|
1718
|
-
["
|
|
1719
|
-
["
|
|
1720
|
-
["
|
|
1721
|
-
["
|
|
1722
|
-
["
|
|
1723
|
-
["
|
|
1724
|
-
["
|
|
1725
|
-
["/", "
|
|
1726
|
-
["
|
|
1727
|
-
["
|
|
1728
|
-
["ctrl+
|
|
1729
|
-
["ctrl+
|
|
1730
|
-
["
|
|
1731
|
-
["ctrl+
|
|
1732
|
-
["
|
|
1733
|
-
["
|
|
1734
|
-
["
|
|
1735
|
-
["
|
|
1736
|
-
["
|
|
1737
|
-
["
|
|
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("
|
|
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", "
|
|
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", "
|
|
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 =
|
|
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
|
-
|
|
1900
|
-
|
|
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
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1976
|
+
color: string,
|
|
1977
|
+
width: number,
|
|
1978
|
+
edges: { title?: string; right?: string; hints?: string },
|
|
1979
|
+
body: string[],
|
|
1905
1980
|
): string[] {
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
|
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("
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
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
|
-
|
|
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
|
|
2119
|
-
const
|
|
2120
|
-
const
|
|
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
|
-
}
|