@zhuxixi/pi-agent-board 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IMPLEMENTATION_PLAN.md +920 -0
- package/LICENSE +21 -0
- package/PRD.md +484 -0
- package/PROGRESS.md +127 -0
- package/README.md +131 -0
- package/VERIFY.md +113 -0
- package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
- package/docs/EXPLORATION.md +187 -0
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
- package/index.ts +6 -0
- package/package.json +81 -0
- package/runner/job-runner.mjs +420 -0
- package/runner/pty-runner.mjs +310 -0
- package/runner/state-runner.mjs +120 -0
- package/runner/title-runner.mjs +80 -0
- package/scripts/patch-vulns.mjs +59 -0
- package/src/commands/agent-board.ts +318 -0
- package/src/commands/attach-flow.ts +231 -0
- package/src/commands/bg.ts +70 -0
- package/src/core/atomic.mjs +145 -0
- package/src/core/auto-state.mjs +320 -0
- package/src/core/dashboard-render.mjs +10 -0
- package/src/core/derive.mjs +114 -0
- package/src/core/diagnostics.mjs +109 -0
- package/src/core/events.mjs +268 -0
- package/src/core/evidence.mjs +242 -0
- package/src/core/follow-up-queue.mjs +193 -0
- package/src/core/heuristics.mjs +240 -0
- package/src/core/ids.mjs +35 -0
- package/src/core/invocation.mjs +43 -0
- package/src/core/launch-options.mjs +317 -0
- package/src/core/launch.mjs +116 -0
- package/src/core/locks.mjs +80 -0
- package/src/core/paths.mjs +86 -0
- package/src/core/pid.mjs +42 -0
- package/src/core/prewarm-schedule.mjs +41 -0
- package/src/core/prompt-transport.mjs +13 -0
- package/src/core/pty-attach-jiggle-retry.mjs +90 -0
- package/src/core/pty-attach-render.mjs +51 -0
- package/src/core/pty-input.mjs +15 -0
- package/src/core/pty-links.mjs +71 -0
- package/src/core/pty-scroll.mjs +155 -0
- package/src/core/pty-support.mjs +327 -0
- package/src/core/repo.mjs +47 -0
- package/src/core/rows.mjs +290 -0
- package/src/core/screen-log-gc.mjs +198 -0
- package/src/core/screen-log.mjs +160 -0
- package/src/core/session-view.mjs +174 -0
- package/src/core/steering-prompts.mjs +34 -0
- package/src/core/steering.mjs +133 -0
- package/src/core/store.mjs +308 -0
- package/src/core/title.mjs +43 -0
- package/src/core/types.mjs +380 -0
- package/src/core/worktree.mjs +64 -0
- package/src/index.ts +109 -0
- package/src/runtime/service.mjs +1194 -0
- package/src/ui/dashboard-evidence.mjs +85 -0
- package/src/ui/dashboard.ts +1952 -0
- package/src/ui/pty-attach.ts +1378 -0
|
@@ -0,0 +1,1952 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-board dashboard: a single full-screen `ctx.ui.custom` component that owns the
|
|
3
|
+
* list, peek/reply, dispatch, filter, rename, and confirm modes plus a live poll loop.
|
|
4
|
+
*
|
|
5
|
+
* It resolves (`done`) only for actions the command handler must run after the surface
|
|
6
|
+
* closes — attaching to a session (tears down the current session) or quitting. Everything
|
|
7
|
+
* else (dispatch, reply, stop, pin, rename, archive) is handled in-place against the store.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { Component, EditorTheme, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
|
|
12
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import { requestDashboardRender } from "../core/dashboard-render.mjs";
|
|
14
|
+
import { isGenericStatusText, normalizeGenericStatusText } from "../core/derive.mjs";
|
|
15
|
+
import { firstSentence, truncate } from "../core/heuristics.mjs";
|
|
16
|
+
import {
|
|
17
|
+
canonicalModelRef,
|
|
18
|
+
clampThinkingLevel,
|
|
19
|
+
listDirectorySuggestions,
|
|
20
|
+
resolveDirectoryValue,
|
|
21
|
+
resolveLaunchContext,
|
|
22
|
+
supportedThinkingLevels,
|
|
23
|
+
} from "../core/launch-options.mjs";
|
|
24
|
+
import { createPrewarmScheduler } from "../core/prewarm-schedule.mjs";
|
|
25
|
+
import { filterRows, groupRowsByFolder, rowState, stateGlyph } from "../core/rows.mjs";
|
|
26
|
+
import { loadSessionView } from "../core/session-view.mjs";
|
|
27
|
+
import { GROUP_LABELS } from "../core/types.mjs";
|
|
28
|
+
import { buildEvidencePanel } from "./dashboard-evidence.mjs";
|
|
29
|
+
import { readState, writeState, type Row } from "../core/store.mjs";
|
|
30
|
+
import type { createService } from "../runtime/service.mjs";
|
|
31
|
+
|
|
32
|
+
type Service = ReturnType<typeof createService>;
|
|
33
|
+
type PtyIssue = {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
statusLabel: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
fixHint: string;
|
|
39
|
+
steps: string[];
|
|
40
|
+
rawReason: string;
|
|
41
|
+
};
|
|
42
|
+
type PtyHealth = { ok: boolean; reason: string | null; staleHosts: number; liveHosts: number; issue?: PtyIssue | null };
|
|
43
|
+
|
|
44
|
+
interface ThemeLike {
|
|
45
|
+
fg(color: string, text: string): string;
|
|
46
|
+
bg(color: string, text: string): string;
|
|
47
|
+
bold(text: string): string;
|
|
48
|
+
italic(text: string): string;
|
|
49
|
+
strikethrough(text: string): string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type DashboardResult = { action: "exit" } | { action: "attach"; viewId: string; stopFirst: boolean };
|
|
53
|
+
|
|
54
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
55
|
+
type LaunchModel = {
|
|
56
|
+
provider: string;
|
|
57
|
+
id: string;
|
|
58
|
+
name?: string;
|
|
59
|
+
reasoning?: boolean;
|
|
60
|
+
thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
|
|
61
|
+
};
|
|
62
|
+
type LaunchChoice = { model: LaunchModel; thinkingLevel?: ThinkingLevel };
|
|
63
|
+
type LaunchPicker = "cwd" | "model" | "thinking" | null;
|
|
64
|
+
|
|
65
|
+
type Mode = "list" | "select" | "dispatch" | "filter" | "peek" | "reply" | "rename" | "confirm" | "help" | "ptyHelp" | "session" | "evidence" | "launch";
|
|
66
|
+
|
|
67
|
+
interface PendingConfirm {
|
|
68
|
+
prompt: string;
|
|
69
|
+
onYes: () => void;
|
|
70
|
+
returnMode?: Mode;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type ReturnableMode = Exclude<Mode, "help">;
|
|
74
|
+
|
|
75
|
+
type InputNoticeColor = "accent" | "success" | "warning" | "muted" | "dim";
|
|
76
|
+
type FlashLevel = "info" | "warn" | "error";
|
|
77
|
+
|
|
78
|
+
interface InputNotice {
|
|
79
|
+
text: string;
|
|
80
|
+
color: InputNoticeColor;
|
|
81
|
+
expiresAt: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface LaunchState {
|
|
85
|
+
fieldIndex: number;
|
|
86
|
+
picker: LaunchPicker;
|
|
87
|
+
action: "background" | "attach";
|
|
88
|
+
cwd: string;
|
|
89
|
+
cwdQuery: string;
|
|
90
|
+
cwdSuggestions: string[];
|
|
91
|
+
cwdSuggestionIndex: number;
|
|
92
|
+
choices: LaunchChoice[];
|
|
93
|
+
scopeSource: "scoped" | "all";
|
|
94
|
+
model: LaunchModel | null;
|
|
95
|
+
modelQuery: string;
|
|
96
|
+
modelFiltered: LaunchChoice[];
|
|
97
|
+
modelIndex: number;
|
|
98
|
+
thinking: ThinkingLevel;
|
|
99
|
+
thinkingOptions: ThinkingLevel[];
|
|
100
|
+
thinkingIndex: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DashboardDeps {
|
|
104
|
+
service: Service;
|
|
105
|
+
defaultCwd: string;
|
|
106
|
+
availableModels: LaunchModel[];
|
|
107
|
+
currentModel: LaunchModel | null;
|
|
108
|
+
currentThinkingLevel: ThinkingLevel;
|
|
109
|
+
initialSelectedId?: string | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class DashboardComponent implements Component {
|
|
113
|
+
private mode: Mode = "list";
|
|
114
|
+
private rows: Row[] = [];
|
|
115
|
+
private orderedIds: string[] = [];
|
|
116
|
+
private selectedId: string | null = null;
|
|
117
|
+
private selectedIds = new Set<string>();
|
|
118
|
+
private input = "";
|
|
119
|
+
private filterQuery = "";
|
|
120
|
+
private pending: PendingConfirm | null = null;
|
|
121
|
+
private deleteArm: { id: string; at: number } | null = null;
|
|
122
|
+
private helpReturnMode: ReturnableMode = "list";
|
|
123
|
+
private ptyHelpReturnMode: ReturnableMode = "list";
|
|
124
|
+
private peekId: string | null = null;
|
|
125
|
+
private scrollTop = 0;
|
|
126
|
+
private sessionScrollTop = 0;
|
|
127
|
+
private evidenceScrollTop = 0;
|
|
128
|
+
private prewarmedId: string | null = null;
|
|
129
|
+
private readonly prewarmScheduler = createPrewarmScheduler(() => this.prewarmSelected(), 200);
|
|
130
|
+
private flash: { text: string; level: FlashLevel } | null = null;
|
|
131
|
+
private inputNotice: InputNotice | null = null;
|
|
132
|
+
private launch: LaunchState | null = null;
|
|
133
|
+
private lastLaunchPrefs: { cwd: string | null; model: string | null; thinkingLevel: ThinkingLevel | null } | null = null;
|
|
134
|
+
private readonly editor: CustomEditor;
|
|
135
|
+
|
|
136
|
+
constructor(
|
|
137
|
+
private readonly tui: TUI,
|
|
138
|
+
private readonly theme: ThemeLike,
|
|
139
|
+
keybindings: KeybindingsManager,
|
|
140
|
+
private readonly done: (result: DashboardResult) => void,
|
|
141
|
+
private readonly deps: DashboardDeps,
|
|
142
|
+
) {
|
|
143
|
+
this.editor = new CustomEditor(tui, editorTheme(theme), keybindings as never, { paddingX: 0 });
|
|
144
|
+
this.editor.onChange = (text) => {
|
|
145
|
+
this.input = this.editor.getExpandedText();
|
|
146
|
+
if (this.mode === "filter") {
|
|
147
|
+
this.filterQuery = text;
|
|
148
|
+
this.refresh();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
this.editor.onSubmit = (text) => this.submitEditor(text);
|
|
152
|
+
this.selectedId = deps.initialSelectedId ?? null;
|
|
153
|
+
try {
|
|
154
|
+
this.lastLaunchPrefs = this.deps.service.getLaunchPrefs?.() ?? null;
|
|
155
|
+
} catch {
|
|
156
|
+
this.lastLaunchPrefs = null;
|
|
157
|
+
}
|
|
158
|
+
this.refresh();
|
|
159
|
+
this.prewarmSelected();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- data ---------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
refresh(): void {
|
|
165
|
+
const previousSelected = this.selectedId;
|
|
166
|
+
const all = this.deps.service.rows();
|
|
167
|
+
this.rows = this.filterQuery ? filterRows(all, this.filterQuery) : all;
|
|
168
|
+
const groups = groupRowsByFolder(this.rows, Date.now());
|
|
169
|
+
this.orderedIds = groups.flatMap((g) => g.folders.flatMap((f) => f.rows.map((r) => r.id)));
|
|
170
|
+
const visibleIds = new Set(this.orderedIds);
|
|
171
|
+
this.selectedIds = new Set([...this.selectedIds].filter((id) => visibleIds.has(id)));
|
|
172
|
+
if (this.orderedIds.length === 0) {
|
|
173
|
+
this.selectedId = null;
|
|
174
|
+
if (this.mode === "select") this.mode = "list";
|
|
175
|
+
} else if (!this.selectedId || !this.orderedIds.includes(this.selectedId)) {
|
|
176
|
+
this.selectedId = this.orderedIds[0];
|
|
177
|
+
}
|
|
178
|
+
if (this.selectedId && this.selectedId !== previousSelected) this.prewarmScheduler.schedule();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private selectedRow(): Row | null {
|
|
182
|
+
const id = this.mode === "peek" || this.mode === "reply" ? this.peekId : this.selectedId;
|
|
183
|
+
return id ? (this.rows.find((r) => r.meta.id === id) ?? this.deps.service.row(id)) : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private selectionCount(): number {
|
|
187
|
+
return this.selectedIds.size;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private unreadCount(): number {
|
|
191
|
+
return this.rows.filter((row) => {
|
|
192
|
+
const lastVisitedAt = row.state?.lastVisitedAt ?? null;
|
|
193
|
+
const lastAgentActivityAt = row.state?.lastAgentActivityAt ?? null;
|
|
194
|
+
return lastAgentActivityAt !== null && (lastVisitedAt === null || lastAgentActivityAt > lastVisitedAt);
|
|
195
|
+
}).length;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private isSelectedForBatch(viewId: string): boolean {
|
|
199
|
+
return this.selectedIds.has(viewId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private startSelectionMode(): void {
|
|
203
|
+
if (!this.selectedId) return;
|
|
204
|
+
this.mode = "select";
|
|
205
|
+
this.selectedIds.clear();
|
|
206
|
+
this.notifyInputState("SELECT mode — 0 selected", "accent");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private openHelp(returnMode: ReturnableMode): void {
|
|
210
|
+
this.helpReturnMode = returnMode;
|
|
211
|
+
this.mode = "help";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private openPtyHelp(returnMode: ReturnableMode): void {
|
|
215
|
+
this.ptyHelpReturnMode = returnMode;
|
|
216
|
+
this.mode = "ptyHelp";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private exitSelectionMode(clear = true): void {
|
|
220
|
+
if (clear) this.selectedIds.clear();
|
|
221
|
+
this.mode = "list";
|
|
222
|
+
this.notifyInputState("NORMAL mode", "muted");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private toggleSelectedCurrent(): void {
|
|
226
|
+
if (!this.selectedId) return;
|
|
227
|
+
if (this.selectedIds.has(this.selectedId)) this.selectedIds.delete(this.selectedId);
|
|
228
|
+
else this.selectedIds.add(this.selectedId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private selectAllVisible(): void {
|
|
232
|
+
this.selectedIds = new Set(this.orderedIds);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private selectedBatchRows(): Row[] {
|
|
236
|
+
return [...this.selectedIds].map((id) => this.deps.service.row(id)).filter((row): row is Row => Boolean(row));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private moveSelection(delta: number): void {
|
|
240
|
+
if (this.orderedIds.length === 0) return;
|
|
241
|
+
const cur = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
|
|
242
|
+
const next = Math.max(0, Math.min(this.orderedIds.length - 1, (cur < 0 ? 0 : cur) + delta));
|
|
243
|
+
const nextId = this.orderedIds[next];
|
|
244
|
+
if (nextId === this.selectedId) return;
|
|
245
|
+
this.selectedId = nextId;
|
|
246
|
+
this.prewarmScheduler.schedule();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private prewarmSelected(): void {
|
|
250
|
+
// A debounced timer can outlive mode changes; never prewarm from modes
|
|
251
|
+
// where selectedId is not the navigation target.
|
|
252
|
+
if (this.mode !== "list" && this.mode !== "select") return;
|
|
253
|
+
const id = this.selectedId;
|
|
254
|
+
if (!id || id === this.prewarmedId) return;
|
|
255
|
+
const row = this.selectedRow();
|
|
256
|
+
if (!row || row.hostAlive || isAgentBusy(row)) return;
|
|
257
|
+
const res = this.deps.service.prewarmHost?.(id);
|
|
258
|
+
if (res?.ok) this.prewarmedId = id;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private notice(text: string, level: FlashLevel = "info"): void {
|
|
262
|
+
this.flash = { text, level };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private notifyInputState(text: string, color: InputNoticeColor): void {
|
|
266
|
+
this.inputNotice = { text, color, expiresAt: Date.now() + 3_000 };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private currentInputNotice(): InputNotice | null {
|
|
270
|
+
if (this.inputNotice && this.inputNotice.expiresAt <= Date.now()) this.inputNotice = null;
|
|
271
|
+
return this.inputNotice;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---- input --------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
handleInput(data: string): void {
|
|
277
|
+
switch (this.mode) {
|
|
278
|
+
case "list":
|
|
279
|
+
this.handleListKey(data);
|
|
280
|
+
break;
|
|
281
|
+
case "select":
|
|
282
|
+
this.handleSelectKey(data);
|
|
283
|
+
break;
|
|
284
|
+
case "dispatch":
|
|
285
|
+
this.handleTextMode(data, () => this.openLaunchDialog(), () => this.leaveDispatchMode());
|
|
286
|
+
break;
|
|
287
|
+
case "filter":
|
|
288
|
+
this.handleTextMode(data, () => this.toListMode(), () => this.clearFilter(), { live: true });
|
|
289
|
+
break;
|
|
290
|
+
case "rename":
|
|
291
|
+
this.handleTextMode(data, () => this.submitRename(), () => this.toListMode());
|
|
292
|
+
break;
|
|
293
|
+
case "reply":
|
|
294
|
+
this.handleTextMode(data, () => this.submitReply(), () => (this.mode = "peek"));
|
|
295
|
+
break;
|
|
296
|
+
case "peek":
|
|
297
|
+
this.handlePeekKey(data);
|
|
298
|
+
break;
|
|
299
|
+
case "session":
|
|
300
|
+
this.handleSessionKey(data);
|
|
301
|
+
break;
|
|
302
|
+
case "evidence":
|
|
303
|
+
this.handleEvidenceKey(data);
|
|
304
|
+
break;
|
|
305
|
+
case "launch":
|
|
306
|
+
this.handleLaunchKey(data);
|
|
307
|
+
break;
|
|
308
|
+
case "confirm":
|
|
309
|
+
this.handleConfirmKey(data);
|
|
310
|
+
break;
|
|
311
|
+
case "help":
|
|
312
|
+
this.mode = this.helpReturnMode;
|
|
313
|
+
break;
|
|
314
|
+
case "ptyHelp":
|
|
315
|
+
this.mode = this.ptyHelpReturnMode;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
requestDashboardRender(this.tui);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private handleListKey(data: string): void {
|
|
322
|
+
if (!matchesKey(data, Key.ctrl("x"))) this.deleteArm = null;
|
|
323
|
+
if (matchesKey(data, Key.up)) return void this.moveSelection(-1);
|
|
324
|
+
if (matchesKey(data, Key.down)) return void this.moveSelection(1);
|
|
325
|
+
if (matchesKey(data, Key.right) || data === ">") return this.attachSelected();
|
|
326
|
+
if (data === "v") return this.openSessionView();
|
|
327
|
+
if (data === "e") return this.openEvidenceView();
|
|
328
|
+
if (matchesKey(data, Key.enter)) {
|
|
329
|
+
if (this.input.trim()) return this.openLaunchDialog();
|
|
330
|
+
return this.attachSelected();
|
|
331
|
+
}
|
|
332
|
+
if (matchesKey(data, Key.space)) return this.openPeek();
|
|
333
|
+
if (data === "m") return this.startSelectionMode();
|
|
334
|
+
if (data === "/") return this.startFilter();
|
|
335
|
+
if (data === "?") return this.openHelp("list");
|
|
336
|
+
if (data === "!") return this.openPtyHelp("list");
|
|
337
|
+
if (data === "i") return this.startDispatch();
|
|
338
|
+
if (matchesKey(data, Key.ctrl("n"))) return this.startDispatch("hello");
|
|
339
|
+
if (matchesKey(data, Key.ctrl("r"))) return this.startRename();
|
|
340
|
+
if (matchesKey(data, Key.ctrl("t"))) return this.togglePin();
|
|
341
|
+
if (matchesKey(data, Key.ctrl("s"))) return this.stopSelected();
|
|
342
|
+
if (data === "d") return this.confirmDone();
|
|
343
|
+
if (matchesKey(data, Key.ctrl("x"))) return this.handleDeleteKey();
|
|
344
|
+
if (data === "X") return this.confirmDeleteState();
|
|
345
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
346
|
+
if (this.input.length > 0) {
|
|
347
|
+
this.setInput("");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
return this.done({ action: "exit" });
|
|
351
|
+
}
|
|
352
|
+
if (isPrintable(data) || isBracketedPaste(data)) {
|
|
353
|
+
this.notifyInputState("Press i to enter INSERT mode, then type or paste", "accent");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private handleSelectKey(data: string): void {
|
|
359
|
+
if (matchesKey(data, Key.up)) return void this.moveSelection(-1);
|
|
360
|
+
if (matchesKey(data, Key.down)) return void this.moveSelection(1);
|
|
361
|
+
if (matchesKey(data, Key.space)) {
|
|
362
|
+
this.toggleSelectedCurrent();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (data === "a") {
|
|
366
|
+
this.selectAllVisible();
|
|
367
|
+
this.notifyInputState(`SELECT mode — ${this.selectionCount()} selected`, "accent");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (data === "d") return this.confirmDoneSelection();
|
|
371
|
+
if (matchesKey(data, Key.ctrl("x"))) return this.confirmDeleteSelection();
|
|
372
|
+
if (data === "u") {
|
|
373
|
+
this.selectedIds.clear();
|
|
374
|
+
this.notifyInputState("SELECT mode — selection cleared", "accent");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (data === "m" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
378
|
+
this.exitSelectionMode(true);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (data === "?") return this.openHelp("select");
|
|
382
|
+
if (data === "!") return this.openPtyHelp("select");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private handlePeekKey(data: string): void {
|
|
386
|
+
if (matchesKey(data, Key.escape)) return void (this.mode = "list");
|
|
387
|
+
if (matchesKey(data, Key.right) || data === ">") return this.attachPeek();
|
|
388
|
+
if (data === "v") return this.openSessionView();
|
|
389
|
+
if (matchesKey(data, Key.up)) {
|
|
390
|
+
this.peekStep(-1);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (matchesKey(data, Key.down)) {
|
|
394
|
+
this.peekStep(1);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (matchesKey(data, Key.enter) || data === "r") return this.startReply();
|
|
398
|
+
if (data === "e") return this.openEvidenceView();
|
|
399
|
+
if (data === "d") return this.confirmDone();
|
|
400
|
+
if (data === "a") return this.attachPeek();
|
|
401
|
+
if (data === "!") return this.openPtyHelp("peek");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private handleSessionKey(data: string): void {
|
|
405
|
+
if (data === "!") return this.openPtyHelp("session");
|
|
406
|
+
const termRows = this.tui.terminal?.rows ?? 24;
|
|
407
|
+
const page = Math.max(1, termRows - 8);
|
|
408
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.escape) || data === "<") {
|
|
409
|
+
const row = this.selectedRow();
|
|
410
|
+
if (row) this.deps.service.markVisited?.(row.meta.id);
|
|
411
|
+
this.mode = "list";
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
415
|
+
this.sessionScrollTop = Math.max(0, this.sessionScrollTop - 1);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
419
|
+
this.sessionScrollTop += 1;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
423
|
+
this.sessionScrollTop = Math.max(0, this.sessionScrollTop - page);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
427
|
+
this.sessionScrollTop += page;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (matchesKey(data, Key.space)) return this.openPeek();
|
|
431
|
+
if (data === "e") return this.openEvidenceView();
|
|
432
|
+
if (data === "r") {
|
|
433
|
+
this.startReply();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (data === "d") return this.confirmDone();
|
|
437
|
+
if (data === "a" || matchesKey(data, Key.enter)) return this.attachSelected();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private handleEvidenceKey(data: string): void {
|
|
441
|
+
const termRows = this.tui.terminal?.rows ?? 24;
|
|
442
|
+
const page = Math.max(1, termRows - 8);
|
|
443
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.escape) || data === "<") {
|
|
444
|
+
this.mode = "list";
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
448
|
+
this.evidenceScrollTop = Math.max(0, this.evidenceScrollTop - 1);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
452
|
+
this.evidenceScrollTop += 1;
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
456
|
+
this.evidenceScrollTop = Math.max(0, this.evidenceScrollTop - page);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
460
|
+
this.evidenceScrollTop += page;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (data === "r") return this.startReply();
|
|
464
|
+
if (data === "v") return this.openSessionView();
|
|
465
|
+
if (data === "a" || matchesKey(data, Key.enter) || matchesKey(data, Key.right)) return this.attachSelected();
|
|
466
|
+
if (data === "x") return this.confirmClearDiagnostics();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private handleConfirmKey(data: string): void {
|
|
470
|
+
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
471
|
+
const action = this.pending?.onYes;
|
|
472
|
+
const returnMode = this.pending?.returnMode ?? "list";
|
|
473
|
+
this.pending = null;
|
|
474
|
+
this.mode = returnMode;
|
|
475
|
+
try {
|
|
476
|
+
action?.();
|
|
477
|
+
} catch (err) {
|
|
478
|
+
this.notice(err instanceof Error ? err.message : String(err), "error");
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// any other key cancels
|
|
483
|
+
const returnMode = this.pending?.returnMode ?? "list";
|
|
484
|
+
this.pending = null;
|
|
485
|
+
this.mode = returnMode;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private handleLaunchKey(data: string): void {
|
|
489
|
+
const launch = this.launch;
|
|
490
|
+
if (!launch) {
|
|
491
|
+
this.mode = "list";
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (launch.picker) {
|
|
495
|
+
this.handleLaunchPickerKey(data, launch);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (matchesKey(data, Key.up)) {
|
|
499
|
+
launch.fieldIndex = launch.fieldIndex === 0 ? 4 : launch.fieldIndex - 1;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (matchesKey(data, Key.down)) {
|
|
503
|
+
launch.fieldIndex = launch.fieldIndex === 4 ? 0 : launch.fieldIndex + 1;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
507
|
+
this.closeLaunchDialog();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (matchesKey(data, Key.enter)) {
|
|
511
|
+
this.activateLaunchField();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (launch.fieldIndex === 1 || launch.fieldIndex === 2) {
|
|
515
|
+
const seeded = this.applyLaunchQueryInput("", data);
|
|
516
|
+
if (seeded !== null) {
|
|
517
|
+
this.openLaunchPicker(launch.fieldIndex === 1 ? "cwd" : "model", seeded);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private handleLaunchPickerKey(data: string, launch: LaunchState): void {
|
|
523
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
524
|
+
launch.picker = null;
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (matchesKey(data, Key.up)) {
|
|
528
|
+
if (launch.picker === "cwd") launch.cwdSuggestionIndex = Math.max(0, launch.cwdSuggestionIndex - 1);
|
|
529
|
+
else if (launch.picker === "model") launch.modelIndex = Math.max(0, launch.modelIndex - 1);
|
|
530
|
+
else launch.thinkingIndex = Math.max(0, launch.thinkingIndex - 1);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (matchesKey(data, Key.down)) {
|
|
534
|
+
if (launch.picker === "cwd") launch.cwdSuggestionIndex = Math.min(Math.max(0, launch.cwdSuggestions.length - 1), launch.cwdSuggestionIndex + 1);
|
|
535
|
+
else if (launch.picker === "model") launch.modelIndex = Math.min(Math.max(0, launch.modelFiltered.length - 1), launch.modelIndex + 1);
|
|
536
|
+
else launch.thinkingIndex = Math.min(Math.max(0, launch.thinkingOptions.length - 1), launch.thinkingIndex + 1);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (matchesKey(data, Key.enter)) {
|
|
540
|
+
if (launch.picker === "cwd") {
|
|
541
|
+
const selected = launch.cwdSuggestions[launch.cwdSuggestionIndex] ?? resolveDirectoryValue(launch.cwdQuery, launch.cwd);
|
|
542
|
+
if (!selected) {
|
|
543
|
+
this.notice("Select an existing folder", "warn");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
this.selectLaunchCwd(selected);
|
|
547
|
+
if (this.launch) this.launch.picker = null;
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (launch.picker === "model") {
|
|
551
|
+
const selected = launch.modelFiltered[launch.modelIndex];
|
|
552
|
+
if (!selected) {
|
|
553
|
+
this.notice("Select a model", "warn");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
this.selectLaunchModel(selected);
|
|
557
|
+
launch.picker = null;
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const selectedThinking = launch.thinkingOptions[launch.thinkingIndex];
|
|
561
|
+
if (selectedThinking) launch.thinking = selectedThinking;
|
|
562
|
+
launch.picker = null;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (launch.picker === "cwd") {
|
|
566
|
+
const next = this.applyLaunchQueryInput(launch.cwdQuery, data);
|
|
567
|
+
if (next !== null) {
|
|
568
|
+
launch.cwdQuery = next;
|
|
569
|
+
launch.cwdSuggestions = listDirectorySuggestions(next, launch.cwd);
|
|
570
|
+
launch.cwdSuggestionIndex = 0;
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (launch.picker === "model") {
|
|
575
|
+
const next = this.applyLaunchQueryInput(launch.modelQuery, data);
|
|
576
|
+
if (next !== null) {
|
|
577
|
+
launch.modelQuery = next;
|
|
578
|
+
launch.modelFiltered = filterLaunchChoices(launch.choices, next);
|
|
579
|
+
launch.modelIndex = 0;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private applyLaunchQueryInput(current: string, data: string): string | null {
|
|
585
|
+
if (matchesKey(data, Key.backspace)) return current.slice(0, -1);
|
|
586
|
+
if (isBracketedPaste(data)) return `${current}${stripBracketedPaste(data)}`;
|
|
587
|
+
if (isPrintable(data)) return `${current}${data}`;
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private activateLaunchField(): void {
|
|
592
|
+
if (!this.launch) return;
|
|
593
|
+
if (this.launch.fieldIndex === 0) {
|
|
594
|
+
this.confirmLaunch();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (this.launch.fieldIndex === 1) return this.openLaunchPicker("cwd");
|
|
598
|
+
if (this.launch.fieldIndex === 2) return this.openLaunchPicker("model", "");
|
|
599
|
+
if (this.launch.fieldIndex === 3) {
|
|
600
|
+
if (this.launch.thinkingOptions.length <= 1) return;
|
|
601
|
+
this.openLaunchPicker("thinking");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (this.launch.fieldIndex === 4) {
|
|
605
|
+
this.launch.action = this.launch.action === "attach" ? "background" : "attach";
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private openLaunchDialog(): void {
|
|
611
|
+
const prompt = this.input.trim();
|
|
612
|
+
if (!prompt) return this.toListMode();
|
|
613
|
+
const defaults = this.launchDefaults();
|
|
614
|
+
this.launch = this.buildLaunchState(defaults.cwd, defaults.model, defaults.thinking);
|
|
615
|
+
this.mode = "launch";
|
|
616
|
+
this.inputNotice = null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private launchDefaults(): { cwd: string; model: LaunchModel | null; thinking: ThinkingLevel } {
|
|
620
|
+
const saved = this.lastLaunchPrefs;
|
|
621
|
+
const cwd = resolveDirectoryValue(saved?.cwd ?? "", this.deps.defaultCwd) ?? this.deps.defaultCwd;
|
|
622
|
+
const model = saved?.model ? findLaunchModelByRef(this.deps.availableModels, saved.model) ?? this.deps.currentModel : this.deps.currentModel;
|
|
623
|
+
const thinking = saved?.thinkingLevel ?? this.deps.currentThinkingLevel;
|
|
624
|
+
return { cwd, model, thinking };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private closeLaunchDialog(): void {
|
|
628
|
+
this.launch = null;
|
|
629
|
+
this.mode = "list";
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private confirmLaunch(): void {
|
|
633
|
+
if (!this.launch) return;
|
|
634
|
+
this.submitDispatch({
|
|
635
|
+
cwd: this.launch.cwd,
|
|
636
|
+
model: this.launch.model ? canonicalModelRef(this.launch.model) : null,
|
|
637
|
+
thinkingLevel: this.launch.thinking,
|
|
638
|
+
attach: this.launch.action === "attach",
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private buildLaunchState(cwd: string, preferredModel: LaunchModel | null, preferredThinking: ThinkingLevel): LaunchState {
|
|
643
|
+
const context = resolveLaunchContext(cwd, this.deps.availableModels, preferredModel, preferredThinking) as {
|
|
644
|
+
choices: LaunchChoice[];
|
|
645
|
+
selectedModel: LaunchModel | null;
|
|
646
|
+
thinking: ThinkingLevel;
|
|
647
|
+
thinkingOptions: ThinkingLevel[];
|
|
648
|
+
scopeSource: "scoped" | "all";
|
|
649
|
+
};
|
|
650
|
+
const initialBrowserCwd = homeLaunchRoot(cwd);
|
|
651
|
+
const modelFiltered = filterLaunchChoices(context.choices, "");
|
|
652
|
+
const modelIndex = Math.max(0, modelFiltered.findIndex((choice) => sameLaunchModel(choice.model, context.selectedModel)));
|
|
653
|
+
const thinkingOptions = supportedThinkingLevels(context.selectedModel) as ThinkingLevel[];
|
|
654
|
+
const thinking = clampThinkingLevel(context.selectedModel, context.thinking, context.thinking);
|
|
655
|
+
const thinkingIndex = Math.max(0, thinkingOptions.indexOf(thinking));
|
|
656
|
+
return {
|
|
657
|
+
fieldIndex: 0,
|
|
658
|
+
picker: null,
|
|
659
|
+
action: "background",
|
|
660
|
+
cwd,
|
|
661
|
+
cwdQuery: initialBrowserCwd,
|
|
662
|
+
cwdSuggestions: listDirectorySuggestions(initialBrowserCwd, cwd),
|
|
663
|
+
cwdSuggestionIndex: 0,
|
|
664
|
+
choices: context.choices,
|
|
665
|
+
scopeSource: context.scopeSource,
|
|
666
|
+
model: context.selectedModel,
|
|
667
|
+
modelQuery: "",
|
|
668
|
+
modelFiltered,
|
|
669
|
+
modelIndex,
|
|
670
|
+
thinking,
|
|
671
|
+
thinkingOptions,
|
|
672
|
+
thinkingIndex,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private openLaunchPicker(picker: Exclude<LaunchPicker, null>, seed?: string): void {
|
|
677
|
+
const launch = this.launch;
|
|
678
|
+
if (!launch) return;
|
|
679
|
+
launch.picker = picker;
|
|
680
|
+
if (picker === "cwd") {
|
|
681
|
+
launch.cwdQuery = seed ?? launch.cwdQuery ?? homeLaunchRoot(launch.cwd);
|
|
682
|
+
launch.cwdSuggestions = listDirectorySuggestions(launch.cwdQuery, launch.cwd);
|
|
683
|
+
launch.cwdSuggestionIndex = 0;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (picker === "model") {
|
|
687
|
+
launch.modelQuery = seed ?? "";
|
|
688
|
+
launch.modelFiltered = filterLaunchChoices(launch.choices, launch.modelQuery);
|
|
689
|
+
const selected = launch.modelFiltered.findIndex((choice) => sameLaunchModel(choice.model, launch.model));
|
|
690
|
+
launch.modelIndex = selected >= 0 ? selected : 0;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
launch.thinkingOptions = supportedThinkingLevels(launch.model) as ThinkingLevel[];
|
|
694
|
+
launch.thinkingIndex = Math.max(0, launch.thinkingOptions.indexOf(launch.thinking));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private selectLaunchCwd(cwd: string): void {
|
|
698
|
+
if (!this.launch) return;
|
|
699
|
+
const next = this.buildLaunchState(cwd, this.launch.model, this.launch.thinking);
|
|
700
|
+
next.action = this.launch.action;
|
|
701
|
+
next.fieldIndex = 1;
|
|
702
|
+
next.cwdQuery = cwd;
|
|
703
|
+
next.cwdSuggestions = listDirectorySuggestions(cwd, cwd);
|
|
704
|
+
this.launch = next;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private selectLaunchModel(choice: LaunchChoice): void {
|
|
708
|
+
if (!this.launch) return;
|
|
709
|
+
this.launch.model = choice.model;
|
|
710
|
+
this.launch.modelQuery = "";
|
|
711
|
+
this.launch.modelFiltered = filterLaunchChoices(this.launch.choices, "");
|
|
712
|
+
this.launch.modelIndex = Math.max(0, this.launch.modelFiltered.findIndex((entry) => sameLaunchModel(entry.model, choice.model)));
|
|
713
|
+
this.launch.thinkingOptions = supportedThinkingLevels(choice.model) as ThinkingLevel[];
|
|
714
|
+
this.launch.thinking = clampThinkingLevel(choice.model, choice.thinkingLevel ?? this.launch.thinking, this.launch.thinking);
|
|
715
|
+
this.launch.thinkingIndex = Math.max(0, this.launch.thinkingOptions.indexOf(this.launch.thinking));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Shared text-editing for input modes. */
|
|
719
|
+
private handleTextMode(data: string, onSubmit: () => void, onCancel: () => void, opts: { live?: boolean } = {}): void {
|
|
720
|
+
void opts.live; // live filtering is driven by editor.onChange.
|
|
721
|
+
if (matchesKey(data, Key.enter)) return onSubmit();
|
|
722
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) return onCancel();
|
|
723
|
+
this.handleEditorInput(data);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private handleEditorInput(data: string): void {
|
|
727
|
+
this.editor.handleInput(data);
|
|
728
|
+
this.input = this.editor.getExpandedText();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private startDispatch(initialText = ""): void {
|
|
732
|
+
this.mode = "dispatch";
|
|
733
|
+
this.notifyInputState("INSERT mode — dashboard shortcuts paused; / is literal", "success");
|
|
734
|
+
if (initialText) this.handleEditorInput(initialText);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private leaveDispatchMode(): void {
|
|
738
|
+
this.mode = "list";
|
|
739
|
+
this.notifyInputState(this.input.trim() ? "NORMAL mode — draft kept; Enter dispatches" : "NORMAL mode — dashboard shortcuts active", "muted");
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private setInput(text: string): void {
|
|
743
|
+
this.input = text;
|
|
744
|
+
this.editor.setText(text);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private submitEditor(textOverride?: string): void {
|
|
748
|
+
if (textOverride !== undefined) this.input = textOverride;
|
|
749
|
+
switch (this.mode) {
|
|
750
|
+
case "filter":
|
|
751
|
+
return this.toListMode();
|
|
752
|
+
case "rename":
|
|
753
|
+
return this.submitRename();
|
|
754
|
+
case "reply":
|
|
755
|
+
return this.submitReply();
|
|
756
|
+
case "list":
|
|
757
|
+
case "dispatch":
|
|
758
|
+
return this.input.trim() ? this.openLaunchDialog() : this.attachSelected();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---- actions ------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
private toListMode(): void {
|
|
765
|
+
this.mode = "list";
|
|
766
|
+
this.setInput("");
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private startFilter(): void {
|
|
770
|
+
this.setInput(this.filterQuery);
|
|
771
|
+
this.mode = "filter";
|
|
772
|
+
this.notifyInputState("FILTER mode — type query; Esc clears", "warning");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private clearFilter(): void {
|
|
776
|
+
this.setInput("");
|
|
777
|
+
this.filterQuery = "";
|
|
778
|
+
this.refresh();
|
|
779
|
+
this.mode = "list";
|
|
780
|
+
this.notifyInputState("Filter cleared — NORMAL mode", "muted");
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private submitDispatch(launchOpts?: { cwd?: string; model?: string | null; thinkingLevel?: ThinkingLevel | null; attach?: boolean }): void {
|
|
784
|
+
const text = this.input.trim();
|
|
785
|
+
if (!text) return this.toListMode();
|
|
786
|
+
const launchCwd = launchOpts?.cwd ?? this.deps.defaultCwd;
|
|
787
|
+
const launchModel = launchOpts?.model ?? (this.launch?.model ? canonicalModelRef(this.launch.model) : null);
|
|
788
|
+
const launchThinking = launchOpts?.thinkingLevel ?? this.launch?.thinking ?? this.deps.currentThinkingLevel;
|
|
789
|
+
const res = this.deps.service.dispatch(text, {
|
|
790
|
+
cwd: launchCwd,
|
|
791
|
+
model: launchModel,
|
|
792
|
+
thinkingLevel: launchThinking,
|
|
793
|
+
});
|
|
794
|
+
if (!res.ok) this.notice(res.error ?? "Dispatch failed", "error");
|
|
795
|
+
else {
|
|
796
|
+
this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
|
|
797
|
+
try {
|
|
798
|
+
this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
|
|
799
|
+
} catch {
|
|
800
|
+
/* best effort */
|
|
801
|
+
}
|
|
802
|
+
this.selectedId = res.viewId ?? this.selectedId;
|
|
803
|
+
if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
|
|
804
|
+
this.setInput("");
|
|
805
|
+
this.launch = null;
|
|
806
|
+
this.mode = "list";
|
|
807
|
+
this.inputNotice = null;
|
|
808
|
+
this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (res.hostMode === "json-runner") {
|
|
812
|
+
this.notice(launchOpts?.attach ? `Start & attach needs PTY; launched in background: ${res.fallbackReason ?? "PTY unavailable"}` : `Dispatched with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
|
|
813
|
+
} else {
|
|
814
|
+
this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
this.setInput("");
|
|
818
|
+
this.launch = null;
|
|
819
|
+
this.mode = "list";
|
|
820
|
+
this.inputNotice = null;
|
|
821
|
+
this.refresh();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private submitReply(): void {
|
|
825
|
+
const text = this.input.trim();
|
|
826
|
+
const row = this.selectedRow();
|
|
827
|
+
if (!text || !row) {
|
|
828
|
+
this.mode = "peek";
|
|
829
|
+
this.setInput("");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const res = this.deps.service.reply(row.meta.id, text);
|
|
833
|
+
if (!res.ok) this.notice(res.error ?? "Reply failed", "error");
|
|
834
|
+
else if (res.hostMode === "json-runner") this.notice(`Reply sent with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
|
|
835
|
+
else this.notice("Reply sent", "info");
|
|
836
|
+
this.setInput("");
|
|
837
|
+
this.mode = "peek";
|
|
838
|
+
this.inputNotice = null;
|
|
839
|
+
this.refresh();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
private submitRename(): void {
|
|
843
|
+
const name = this.input.trim();
|
|
844
|
+
if (name && this.selectedId) {
|
|
845
|
+
const res = this.deps.service.rename(this.selectedId, name);
|
|
846
|
+
if (!res.ok) this.notice(res.error ?? "Rename failed", "error");
|
|
847
|
+
}
|
|
848
|
+
this.toListMode();
|
|
849
|
+
this.refresh();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
private startRename(): void {
|
|
853
|
+
const row = this.selectedRow();
|
|
854
|
+
if (!row) return;
|
|
855
|
+
this.setInput(row.meta.name);
|
|
856
|
+
this.mode = "rename";
|
|
857
|
+
this.notifyInputState("RENAME mode — Enter saves; Esc cancels", "accent");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private togglePin(): void {
|
|
861
|
+
const row = this.selectedRow();
|
|
862
|
+
if (!row) return;
|
|
863
|
+
this.deps.service.setPinned(row.meta.id, !row.meta.pinned);
|
|
864
|
+
this.refresh();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private stopSelected(): void {
|
|
868
|
+
const row = this.selectedRow();
|
|
869
|
+
if (!row) return;
|
|
870
|
+
if (!row.alive && !row.hostAlive) return this.notice("No active run or host to stop", "warn");
|
|
871
|
+
const res = this.deps.service.stop(row.meta.id);
|
|
872
|
+
this.notice(res.ok ? "Stopping…" : (res.error ?? "Stop failed"), res.ok ? "info" : "warn");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private confirmDone(): void {
|
|
876
|
+
const row = this.selectedRow();
|
|
877
|
+
if (!row) return;
|
|
878
|
+
if (isAgentBusy(row)) return this.notice("Wait for the active run to finish before marking done", "warn");
|
|
879
|
+
if (row.state?.semanticState === "completed") return this.notice("Already marked done", "info");
|
|
880
|
+
this.pending = {
|
|
881
|
+
prompt: `Mark "${row.meta.name}" as done? (y/N)`,
|
|
882
|
+
onYes: () => {
|
|
883
|
+
const res = this.markCompleted(row);
|
|
884
|
+
if (!res.ok) this.notice(res.error ?? "Mark done failed", "error");
|
|
885
|
+
else this.notice("Marked done", "info");
|
|
886
|
+
this.refresh();
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
this.mode = "confirm";
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private confirmDoneSelection(): void {
|
|
893
|
+
const rows = this.selectedBatchRows();
|
|
894
|
+
if (rows.length === 0) return this.notice("Select one or more sessions first", "warn");
|
|
895
|
+
const doneIds = rows.filter((row) => !isAgentBusy(row) && row.state?.semanticState !== "completed").map((row) => row.meta.id);
|
|
896
|
+
const skipped = rows.length - doneIds.length;
|
|
897
|
+
if (doneIds.length === 0) return this.notice("No inactive sessions in the selection can be moved to Done", "warn");
|
|
898
|
+
this.pending = {
|
|
899
|
+
prompt: `Mark ${doneIds.length} selected session${doneIds.length === 1 ? "" : "s"} as done?${skipped ? ` ${skipped} will be skipped.` : ""} (y/N)`,
|
|
900
|
+
returnMode: "select",
|
|
901
|
+
onYes: () => {
|
|
902
|
+
const res = this.deps.service.markCompletedMany?.(doneIds) ?? { ok: true, completed: 0, skipped: doneIds.length, completedIds: [] };
|
|
903
|
+
if (!res.ok) this.notice("Mark done failed", "error");
|
|
904
|
+
else {
|
|
905
|
+
if (res.completedIds?.length) this.selectedId = res.completedIds[0];
|
|
906
|
+
this.notice(`Moved ${res.completed} to Done${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
|
|
907
|
+
}
|
|
908
|
+
this.refresh();
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
this.mode = "confirm";
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private markCompleted(row: Row): { ok: boolean; error?: string } {
|
|
915
|
+
const service = this.deps.service as Service & { markCompleted?: (viewId: string) => { ok: boolean; error?: string } };
|
|
916
|
+
if (typeof service.markCompleted === "function") return service.markCompleted(row.meta.id);
|
|
917
|
+
|
|
918
|
+
// Compatibility guard for an already-open dashboard whose service object came
|
|
919
|
+
// from an older module instance. The service owns this path normally.
|
|
920
|
+
const state = readState(service.root, row.meta.id) ?? row.state ?? {
|
|
921
|
+
version: 1,
|
|
922
|
+
viewId: row.meta.id,
|
|
923
|
+
currentRunId: null,
|
|
924
|
+
semanticState: "idle",
|
|
925
|
+
processState: "exited",
|
|
926
|
+
summary: "In Progress",
|
|
927
|
+
lastActivityAt: Date.now(),
|
|
928
|
+
updatedAt: Date.now(),
|
|
929
|
+
needsInput: false,
|
|
930
|
+
hasError: false,
|
|
931
|
+
latestAssistantPreview: "",
|
|
932
|
+
latestTool: null,
|
|
933
|
+
question: null,
|
|
934
|
+
pendingQuestions: [],
|
|
935
|
+
error: null,
|
|
936
|
+
lastVisitedAt: null,
|
|
937
|
+
lastAgentActivityAt: null,
|
|
938
|
+
autoState: null,
|
|
939
|
+
};
|
|
940
|
+
state.semanticState = "completed";
|
|
941
|
+
state.processState = "exited";
|
|
942
|
+
state.needsInput = false;
|
|
943
|
+
state.hasError = false;
|
|
944
|
+
state.question = null;
|
|
945
|
+
state.pendingQuestions = [];
|
|
946
|
+
state.error = null;
|
|
947
|
+
state.autoState = null;
|
|
948
|
+
state.summary = completedSummary(state.summary, state.latestAssistantPreview);
|
|
949
|
+
state.lastActivityAt = Date.now();
|
|
950
|
+
state.updatedAt = Date.now();
|
|
951
|
+
writeState(service.root, state);
|
|
952
|
+
return { ok: true };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private handleDeleteKey(): void {
|
|
956
|
+
const row = this.selectedRow();
|
|
957
|
+
if (!row) return;
|
|
958
|
+
const now = Date.now();
|
|
959
|
+
if (this.deleteArm !== null && this.deleteArm.id === row.meta.id && now - this.deleteArm.at <= DELETE_DOUBLE_PRESS_MS) {
|
|
960
|
+
this.deleteArm = null;
|
|
961
|
+
const res = this.deps.service.archive(row.meta.id);
|
|
962
|
+
if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
|
|
963
|
+
else this.notice(`Deleted "${row.meta.name}"`, "info");
|
|
964
|
+
this.refresh();
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
this.deleteArm = { id: row.meta.id, at: now };
|
|
968
|
+
this.notifyInputState(`Press ctrl+x again quickly to delete "${row.meta.name}"${isAgentBusy(row) ? " (stops the active run)" : ""}`, "warning");
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private confirmDeleteState(): void {
|
|
972
|
+
const row = this.selectedRow();
|
|
973
|
+
if (!row) return;
|
|
974
|
+
const state = rowState(row);
|
|
975
|
+
const matching = this.deps.service.rows().filter((r) => rowState(r) === state);
|
|
976
|
+
const deletable = matching.filter((r) => !isAgentBusy(r)).length;
|
|
977
|
+
const skipped = matching.length - deletable;
|
|
978
|
+
if (deletable === 0) return this.notice(`No inactive ${GROUP_LABELS[state].toLowerCase()} sessions to delete`, "warn");
|
|
979
|
+
this.pending = {
|
|
980
|
+
prompt: `Delete ${deletable} ${GROUP_LABELS[state].toLowerCase()} session${deletable === 1 ? "" : "s"}?${skipped ? ` ${skipped} live skipped.` : ""} (y/N)`,
|
|
981
|
+
onYes: () => {
|
|
982
|
+
const res = this.deps.service.archiveByState(state);
|
|
983
|
+
if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
|
|
984
|
+
else this.notice(`Deleted ${res.archived}${res.skipped ? ` · skipped ${res.skipped} live` : ""}`, "info");
|
|
985
|
+
this.refresh();
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
this.mode = "confirm";
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private confirmDeleteSelection(): void {
|
|
992
|
+
const rows = this.selectedBatchRows();
|
|
993
|
+
if (rows.length === 0) return this.notice("Select one or more Done sessions first", "warn");
|
|
994
|
+
if (rows.some((row) => rowState(row) !== "completed")) return this.notice("Only Done sessions can be batch deleted", "warn");
|
|
995
|
+
this.pending = {
|
|
996
|
+
prompt: `Delete ${rows.length} done session${rows.length === 1 ? "" : "s"}? Session files are preserved. (y/N)`,
|
|
997
|
+
returnMode: "select",
|
|
998
|
+
onYes: () => {
|
|
999
|
+
const res = this.deps.service.archiveMany?.(rows.map((row) => row.meta.id)) ?? { ok: true, archived: 0, skipped: rows.length };
|
|
1000
|
+
if (!res.ok) this.notice("Delete failed", "error");
|
|
1001
|
+
else this.notice(`Deleted ${res.archived}${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
|
|
1002
|
+
this.exitSelectionMode(true);
|
|
1003
|
+
this.refresh();
|
|
1004
|
+
},
|
|
1005
|
+
};
|
|
1006
|
+
this.mode = "confirm";
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private openPeek(): void {
|
|
1010
|
+
if (!this.selectedId) return;
|
|
1011
|
+
this.peekId = this.selectedId;
|
|
1012
|
+
this.mode = "peek";
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private startReply(): void {
|
|
1016
|
+
this.peekId = this.peekId ?? this.selectedId;
|
|
1017
|
+
this.setInput("");
|
|
1018
|
+
this.mode = "reply";
|
|
1019
|
+
this.notifyInputState("REPLY INSERT mode — arrows edit; Esc returns to peek", "success");
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
private openSessionView(): void {
|
|
1023
|
+
if (!this.selectedId) return;
|
|
1024
|
+
this.deps.service.markVisited?.(this.selectedId);
|
|
1025
|
+
this.sessionScrollTop = 0;
|
|
1026
|
+
this.mode = "session";
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private openEvidenceView(): void {
|
|
1030
|
+
if (!this.selectedId) return;
|
|
1031
|
+
this.evidenceScrollTop = 0;
|
|
1032
|
+
this.mode = "evidence";
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private confirmClearDiagnostics(): void {
|
|
1036
|
+
const row = this.selectedRow();
|
|
1037
|
+
if (!row) return;
|
|
1038
|
+
this.pending = {
|
|
1039
|
+
prompt: `Clear diagnostics for "${row.meta.name}"? Evidence is preserved. (y/N)`,
|
|
1040
|
+
returnMode: "evidence",
|
|
1041
|
+
onYes: () => {
|
|
1042
|
+
const res = this.deps.service.clearDiagnostics?.(row.meta.id) as { ok?: boolean; error?: string } | undefined;
|
|
1043
|
+
if (!res?.ok) this.notice(res?.error ?? "Clear diagnostics failed", "error");
|
|
1044
|
+
else this.notice("Diagnostics cleared", "info");
|
|
1045
|
+
this.refresh();
|
|
1046
|
+
},
|
|
1047
|
+
};
|
|
1048
|
+
this.mode = "confirm";
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private peekStep(delta: number): void {
|
|
1052
|
+
if (!this.peekId) return;
|
|
1053
|
+
const idx = this.orderedIds.indexOf(this.peekId);
|
|
1054
|
+
if (idx < 0) return;
|
|
1055
|
+
const next = Math.max(0, Math.min(this.orderedIds.length - 1, idx + delta));
|
|
1056
|
+
this.peekId = this.orderedIds[next];
|
|
1057
|
+
this.selectedId = this.peekId;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
private attachSelected(): void {
|
|
1061
|
+
const row = this.selectedRow();
|
|
1062
|
+
if (!row) return;
|
|
1063
|
+
this.requestAttach(row);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private attachPeek(): void {
|
|
1067
|
+
const row = this.selectedRow();
|
|
1068
|
+
if (row) this.requestAttach(row);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private requestAttach(row: Row): void {
|
|
1072
|
+
if (row.hostAlive) {
|
|
1073
|
+
this.done({ action: "attach", viewId: row.meta.id, stopFirst: false });
|
|
1074
|
+
} else if (isAgentBusy(row)) {
|
|
1075
|
+
this.pending = {
|
|
1076
|
+
prompt: `"${row.meta.name}" is running. Interrupt and attach? (y/N)`,
|
|
1077
|
+
onYes: () => this.done({ action: "attach", viewId: row.meta.id, stopFirst: true }),
|
|
1078
|
+
};
|
|
1079
|
+
this.mode = "confirm";
|
|
1080
|
+
} else {
|
|
1081
|
+
this.done({ action: "attach", viewId: row.meta.id, stopFirst: false });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ---- rendering ----------------------------------------------------------
|
|
1086
|
+
|
|
1087
|
+
invalidate(): void {
|
|
1088
|
+
/* stateless render; nothing cached */
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
dispose(): void {
|
|
1092
|
+
/* poll interval is owned by the factory */
|
|
1093
|
+
this.prewarmScheduler.cancel();
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
render(width: number): string[] {
|
|
1097
|
+
const allRows = this.deps.service.rows();
|
|
1098
|
+
const needs = allRows.filter((r) => r.state?.semanticState === "needs_input").length;
|
|
1099
|
+
const working = allRows.filter((r) => r.state?.semanticState === "working").length;
|
|
1100
|
+
const completed = allRows.filter((r) => r.state?.semanticState === "completed").length;
|
|
1101
|
+
const unread = allRows.filter((r) => {
|
|
1102
|
+
const lastVisitedAt = r.state?.lastVisitedAt ?? null;
|
|
1103
|
+
const lastAgentActivityAt = r.state?.lastAgentActivityAt ?? null;
|
|
1104
|
+
return lastAgentActivityAt !== null && (lastVisitedAt === null || lastAgentActivityAt > lastVisitedAt);
|
|
1105
|
+
}).length;
|
|
1106
|
+
const lines: string[] = [];
|
|
1107
|
+
const ptyHealth = this.ptyHealth();
|
|
1108
|
+
|
|
1109
|
+
lines.push(...this.renderOverview(width, { needs, working, completed, unread }, ptyHealth));
|
|
1110
|
+
if (!ptyHealth.ok && ptyHealth.issue) lines.push(...renderPtyWarningBanner(this.theme, ptyHealth.issue, width));
|
|
1111
|
+
if (this.flash) lines.push(...renderFlashBanner(this.flash, width, { bottomGap: true }));
|
|
1112
|
+
|
|
1113
|
+
if (this.mode === "help") return this.fitToHeight(this.renderHelp(width), width);
|
|
1114
|
+
if (this.mode === "ptyHelp") return this.fitToHeight(this.renderPtyHelp(width, ptyHealth), width);
|
|
1115
|
+
if (this.mode === "peek" || this.mode === "reply") return this.fitToHeight(lines.concat(this.renderPeek(width)), width);
|
|
1116
|
+
if (this.mode === "session") return this.fitToHeight(lines.concat(this.renderSession(width)), width);
|
|
1117
|
+
if (this.mode === "evidence") return this.fitToHeight(lines.concat(this.renderEvidence(width)), width);
|
|
1118
|
+
if (this.mode === "launch") return this.fitToHeight(lines.concat(this.renderLaunch(width)), width);
|
|
1119
|
+
|
|
1120
|
+
// Body: grouped rows with a scroll viewport.
|
|
1121
|
+
const footer = this.renderFooter(width);
|
|
1122
|
+
const capacity = Math.max(1, (this.tui.terminal?.rows ?? 24) - lines.length - footer.length);
|
|
1123
|
+
const body = this.renderRows(width);
|
|
1124
|
+
const windowed = this.windowBody(body, capacity);
|
|
1125
|
+
lines.push(...windowed.lines);
|
|
1126
|
+
// Keep the compose box visually docked to the bottom instead of glued to the
|
|
1127
|
+
// final session row. This matches the Claude-style layout: list at top,
|
|
1128
|
+
// large calm workspace, input/footer at bottom.
|
|
1129
|
+
const spacer = Math.max(0, capacity - windowed.lines.length);
|
|
1130
|
+
for (let i = 0; i < spacer; i++) lines.push("");
|
|
1131
|
+
|
|
1132
|
+
lines.push(...footer);
|
|
1133
|
+
return this.fitToHeight(lines, width);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private fitToHeight(lines: string[], width: number): string[] {
|
|
1137
|
+
const height = this.tui.terminal?.rows ?? 24;
|
|
1138
|
+
const out = lines.slice(0, height).map((l) => clip(l, width));
|
|
1139
|
+
while (out.length < height) out.push("");
|
|
1140
|
+
return out;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private ptyHealth(): PtyHealth {
|
|
1144
|
+
return this.deps.service.ptyHealth?.() ?? {
|
|
1145
|
+
ok: false,
|
|
1146
|
+
reason: "unknown",
|
|
1147
|
+
issue: null,
|
|
1148
|
+
staleHosts: 0,
|
|
1149
|
+
liveHosts: 0,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
private renderOverview(
|
|
1154
|
+
width: number,
|
|
1155
|
+
counts: { needs: number; working: number; completed: number; unread: number },
|
|
1156
|
+
ptyHealth: PtyHealth,
|
|
1157
|
+
): string[] {
|
|
1158
|
+
return renderAgentboardHeader(width, this.theme, counts, ptyHealth, this.filterQuery);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
private renderFooter(width: number): string[] {
|
|
1162
|
+
const t = this.theme;
|
|
1163
|
+
const modeColor = this.modeColor();
|
|
1164
|
+
const lines = [t.fg(modeColor, "─".repeat(width))];
|
|
1165
|
+
if (this.mode === "list" || this.mode === "dispatch" || this.mode === "select") {
|
|
1166
|
+
lines.push(...this.taskInputLines(width));
|
|
1167
|
+
lines.push("");
|
|
1168
|
+
lines.push(clip(this.listHints(), width));
|
|
1169
|
+
} else if (this.mode === "filter") {
|
|
1170
|
+
lines.push(...this.renderEditorInputLines(width, {
|
|
1171
|
+
prompt: `${this.modeBadge("FILTER", "warning")} ${t.fg("warning", "› ")}`,
|
|
1172
|
+
continuation: t.fg("warning", "│ "),
|
|
1173
|
+
editing: true,
|
|
1174
|
+
placeholder: "",
|
|
1175
|
+
}));
|
|
1176
|
+
lines.push("");
|
|
1177
|
+
lines.push(clip(this.hintLine("FILTER", "warning", ["enter apply", "esc clear"]), width));
|
|
1178
|
+
} else if (this.mode === "rename") {
|
|
1179
|
+
lines.push(...this.renderEditorInputLines(width, {
|
|
1180
|
+
prompt: `${this.modeBadge("RENAME", "accent")} ${t.fg("accent", "› ")}`,
|
|
1181
|
+
continuation: t.fg("accent", "│ "),
|
|
1182
|
+
editing: true,
|
|
1183
|
+
placeholder: "",
|
|
1184
|
+
}));
|
|
1185
|
+
lines.push("");
|
|
1186
|
+
lines.push(clip(this.hintLine("RENAME", "accent", ["enter save", "esc cancel"]), width));
|
|
1187
|
+
} else if (this.mode === "confirm" && this.pending) {
|
|
1188
|
+
lines.push(...renderFlashBanner({ text: this.pending.prompt, level: "warn" }, width));
|
|
1189
|
+
} else {
|
|
1190
|
+
lines.push(clip(this.listHints(), width));
|
|
1191
|
+
}
|
|
1192
|
+
const notice = this.currentInputNotice();
|
|
1193
|
+
if (notice) lines.push(clip(t.fg(notice.color, `ⓘ ${notice.text}`), width));
|
|
1194
|
+
lines.push(t.fg(modeColor, "─".repeat(width)));
|
|
1195
|
+
return lines;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private listHints(): string {
|
|
1199
|
+
const selected = this.selectedRow();
|
|
1200
|
+
const live = selected ? selected.hostAlive && isAgentBusy(selected) : false;
|
|
1201
|
+
const unread = this.unreadCount();
|
|
1202
|
+
if (this.mode === "dispatch") {
|
|
1203
|
+
return this.hintLine("INSERT", "success", ["esc normal", "enter launch", "shift+enter newline", "←/→ edit", "ctrl/alt+←/→ word"]);
|
|
1204
|
+
}
|
|
1205
|
+
if (this.mode === "select") {
|
|
1206
|
+
return this.hintLine("SELECT", "accent", [
|
|
1207
|
+
`${this.selectionCount()} selected`,
|
|
1208
|
+
...(unread > 0 ? [`•${unread} unread`] : []),
|
|
1209
|
+
"space toggle",
|
|
1210
|
+
"a all visible",
|
|
1211
|
+
"u clear",
|
|
1212
|
+
"d move done",
|
|
1213
|
+
"ctrl+x delete done",
|
|
1214
|
+
"esc cancel",
|
|
1215
|
+
]);
|
|
1216
|
+
}
|
|
1217
|
+
const primary = this.input.trim() ? "enter launch" : live ? "enter attach live" : "enter resume";
|
|
1218
|
+
const hints = ["i insert", primary, "→ attach", "m multi-select", ...(unread > 0 ? [`•${unread} unread`] : []), "d done", "space peek", "v transcript", "e evidence", "ctrl+n new session", "ctrl+r rename", "ctrl+x x2 delete", "X delete state", "/ filter", "! pty", "? help"];
|
|
1219
|
+
if (this.input.trim()) hints.splice(1, 0, "esc clear");
|
|
1220
|
+
return this.hintLine("NORMAL", "muted", hints);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
private hintLine(label: string, color: InputNoticeColor, hints: string[]): string {
|
|
1224
|
+
return `${this.modeBadge(label, color)} ${this.theme.fg("dim", hints.join(" · "))}`;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
private modeBadge(label: string, color: InputNoticeColor): string {
|
|
1228
|
+
return this.theme.fg(color, this.theme.bold(`[${label}]`));
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
private modeColor(): InputNoticeColor {
|
|
1232
|
+
switch (this.mode) {
|
|
1233
|
+
case "dispatch":
|
|
1234
|
+
case "reply":
|
|
1235
|
+
return "success";
|
|
1236
|
+
case "filter":
|
|
1237
|
+
case "confirm":
|
|
1238
|
+
return "warning";
|
|
1239
|
+
case "rename":
|
|
1240
|
+
case "select":
|
|
1241
|
+
return "accent";
|
|
1242
|
+
default:
|
|
1243
|
+
return "dim";
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private taskInputLines(width: number): string[] {
|
|
1248
|
+
const editing = this.mode === "dispatch";
|
|
1249
|
+
return this.renderEditorInputLines(width, {
|
|
1250
|
+
prompt: editing ? this.theme.fg("success", "┃ ") : this.theme.fg("muted", "› "),
|
|
1251
|
+
continuation: editing ? this.theme.fg("success", "┃ ") : this.theme.fg("dim", "│ "),
|
|
1252
|
+
editing,
|
|
1253
|
+
placeholder: editing ? "" : "press i to write a prompt for a new session",
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private renderEditorInputLines(
|
|
1258
|
+
width: number,
|
|
1259
|
+
opts: { prompt: string; continuation: string; editing: boolean; placeholder?: string; maxVisible?: number },
|
|
1260
|
+
): string[] {
|
|
1261
|
+
void opts.continuation;
|
|
1262
|
+
void opts.maxVisible;
|
|
1263
|
+
const text = this.input;
|
|
1264
|
+
if (!text && !opts.editing) {
|
|
1265
|
+
const contentWidth = Math.max(1, width - visibleWidth(opts.prompt));
|
|
1266
|
+
return [clip(`${opts.prompt}${clip(this.theme.fg("muted", opts.placeholder ?? ""), contentWidth)}`, width)];
|
|
1267
|
+
}
|
|
1268
|
+
const rendered = this.editor.render(width);
|
|
1269
|
+
return opts.editing ? rendered : rendered.map((line) => stripInactiveEditorCursor(line));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
private renderRows(width: number): string[] {
|
|
1273
|
+
const t = this.theme;
|
|
1274
|
+
if (this.rows.length === 0) {
|
|
1275
|
+
return [t.fg("muted", this.filterQuery ? " No sessions match the filter." : " No background sessions yet. Type below and press Enter.")];
|
|
1276
|
+
}
|
|
1277
|
+
const now = Date.now();
|
|
1278
|
+
const groups = groupRowsByFolder(this.rows, now);
|
|
1279
|
+
const out: string[] = [];
|
|
1280
|
+
for (const g of groups) {
|
|
1281
|
+
if (out.length > 0) out.push("");
|
|
1282
|
+
out.push(this.renderStageHeader(g.state, g.label, g.rowCount, width));
|
|
1283
|
+
const showFolderHeaders = g.showFolders;
|
|
1284
|
+
for (const folder of g.folders) {
|
|
1285
|
+
if (showFolderHeaders) out.push(this.renderFolderHeader(g.state, folder, width));
|
|
1286
|
+
for (const rv of folder.rows) {
|
|
1287
|
+
out.push(this.renderRow(rv, width, { showFolder: !showFolderHeaders, indent: showFolderHeaders ? 4 : 2 }));
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return out;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
private renderStageHeader(state: keyof typeof GROUP_LABELS, label: string, count: number, width: number): string {
|
|
1295
|
+
const t = this.theme;
|
|
1296
|
+
const glyph = stateGlyph(state, state === "working");
|
|
1297
|
+
const title = stageFg(state, t.bold(`${glyph} ${label.toUpperCase()}`));
|
|
1298
|
+
const countText = t.fg("dim", ` ${count}`);
|
|
1299
|
+
const ruleWidth = Math.max(0, width - visibleWidth(`${glyph} ${label.toUpperCase()} ${count}`) - 2);
|
|
1300
|
+
return clip(`${title}${countText} ${t.fg("borderMuted", "─".repeat(ruleWidth))}`, width);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private renderFolderHeader(state: keyof typeof GROUP_LABELS, folder: ReturnType<typeof groupRowsByFolder>[number]["folders"][number], width: number): string {
|
|
1304
|
+
const t = this.theme;
|
|
1305
|
+
const count = folder.rows.length;
|
|
1306
|
+
const selectedInside = folder.rows.some((row) => row.id === this.selectedId);
|
|
1307
|
+
const rail = selectedInside ? stageFg(state, "▌") : t.fg("dim", " ");
|
|
1308
|
+
const name = t.fg(selectedInside ? "text" : "muted", `▾ ${folder.name}`);
|
|
1309
|
+
const countText = t.fg("dim", ` ${count}`);
|
|
1310
|
+
const pathBudget = Math.max(0, width - visibleWidth(`${rail} ▾ ${folder.name} ${count}`) - 2);
|
|
1311
|
+
const path = pathBudget > 14 ? ` ${t.fg("dim", truncate(displayPath(folder.path), pathBudget))}` : "";
|
|
1312
|
+
return clip(` ${rail} ${name}${countText}${path}`, width);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
private renderRow(rv: ReturnType<typeof import("../core/rows.mjs")["rowView"]>, width: number, opts: { showFolder?: boolean; indent?: number } = {}): string {
|
|
1316
|
+
const t = this.theme;
|
|
1317
|
+
const selected = rv.id === this.selectedId;
|
|
1318
|
+
const indent = opts.indent ?? 0;
|
|
1319
|
+
const contentWidth = Math.max(20, width - indent);
|
|
1320
|
+
const glyph = stateGlyph(rv.state, rv.alive, rv.hostAlive, rv.unread);
|
|
1321
|
+
const marker = stageFg(rv.state, selected ? `›${glyph}` : ` ${glyph}`);
|
|
1322
|
+
const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
|
|
1323
|
+
const badge = `${this.mode === "select" ? `${this.isSelectedForBatch(rv.id) ? "◉" : "○"} ` : ""}${rv.pinned ? "★ " : ""}${rv.worktree ? "⌥ " : ""}${statusBadges}`;
|
|
1324
|
+
const ageRaw = ` ${rv.age}`;
|
|
1325
|
+
const nameW = clamp(Math.floor(contentWidth * 0.34), 18, 30);
|
|
1326
|
+
const folderW = opts.showFolder !== false ? (contentWidth >= 72 ? clamp(Math.floor(contentWidth * 0.16), 10, 22) : contentWidth >= 56 ? 10 : 0) : 0;
|
|
1327
|
+
const availableName = Math.max(8, nameW - visibleWidth(badge));
|
|
1328
|
+
const nameText = padTo(`${badge}${truncate(rv.name, availableName)}`, nameW);
|
|
1329
|
+
const folderText = folderW > 0 ? padTo(truncate(rv.place, folderW), folderW) : "";
|
|
1330
|
+
const folder = folderW > 0 ? ` ${t.fg("dim", folderText)}` : "";
|
|
1331
|
+
const summaryW = Math.max(8, contentWidth - nameW - visibleWidth(folder) - visibleWidth(ageRaw) - 4);
|
|
1332
|
+
const summary = truncate(rv.summary, summaryW);
|
|
1333
|
+
let line = `${marker} ${t.fg(selected ? "text" : "muted", selected ? t.bold(nameText) : nameText)}${folder} ${t.fg(selected ? "text" : "muted", summary)}`;
|
|
1334
|
+
line = padTo(line, contentWidth - visibleWidth(ageRaw));
|
|
1335
|
+
line += t.fg("dim", ageRaw);
|
|
1336
|
+
return clip(`${" ".repeat(indent)}${clip(line, contentWidth)}`, width);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
private renderPeek(width: number): string[] {
|
|
1340
|
+
const t = this.theme;
|
|
1341
|
+
const row = this.selectedRow();
|
|
1342
|
+
if (!row) return [t.fg("muted", " (no session)")];
|
|
1343
|
+
const st = rowState(row);
|
|
1344
|
+
const out: string[] = [];
|
|
1345
|
+
out.push(`${stageFg(st, stateGlyph(st, row.alive, row.hostAlive))} ${t.fg("accent", t.bold(row.meta.name))} ${t.fg("muted", `[${st}${row.alive ? " · alive" : ""}${row.hostAlive ? " · hosted" : ""}]`)}`);
|
|
1346
|
+
out.push(t.fg("dim", ` ${row.meta.repoCwd}${row.meta.worktreeMode === "worktree" ? " (worktree)" : ""}`));
|
|
1347
|
+
out.push("");
|
|
1348
|
+
out.push(t.fg("muted", "Summary"));
|
|
1349
|
+
out.push(clip(` ${normalizeGenericStatusText(st, row.state?.summary)}`, width));
|
|
1350
|
+
if (row.state?.autoState) {
|
|
1351
|
+
const auto = row.state.autoState;
|
|
1352
|
+
out.push("");
|
|
1353
|
+
out.push(t.fg("muted", "Auto-state"));
|
|
1354
|
+
out.push(clip(` ${auto.kind} · ${auto.confidence} · ${auto.source}${auto.reason ? ` — ${auto.reason}` : ""}`, width));
|
|
1355
|
+
}
|
|
1356
|
+
if (row.state?.question) {
|
|
1357
|
+
out.push("");
|
|
1358
|
+
out.push(t.fg("warning", "Question / blocker"));
|
|
1359
|
+
out.push(...wrap(` ${row.state.question}`, width));
|
|
1360
|
+
}
|
|
1361
|
+
out.push("");
|
|
1362
|
+
out.push(t.fg("muted", "Latest output"));
|
|
1363
|
+
out.push(...wrap(` ${row.state?.latestAssistantPreview || "—"}`, width, 8));
|
|
1364
|
+
if (row.state?.error) {
|
|
1365
|
+
out.push("");
|
|
1366
|
+
out.push(t.fg("error", "Error"));
|
|
1367
|
+
out.push(...wrap(` ${row.state.error}`, width, 4));
|
|
1368
|
+
}
|
|
1369
|
+
out.push(t.fg(this.mode === "reply" ? "success" : "dim", "─".repeat(width)));
|
|
1370
|
+
if (this.mode === "reply") {
|
|
1371
|
+
out.push(
|
|
1372
|
+
...this.renderEditorInputLines(width, {
|
|
1373
|
+
prompt: `${this.modeBadge("REPLY", "success")} ${t.fg("success", "› ")}`,
|
|
1374
|
+
continuation: t.fg("success", "reply┃ "),
|
|
1375
|
+
editing: true,
|
|
1376
|
+
placeholder: "",
|
|
1377
|
+
}),
|
|
1378
|
+
);
|
|
1379
|
+
out.push(clip(this.hintLine("REPLY INSERT", "success", ["enter send", "shift+enter newline", "esc back"]), width));
|
|
1380
|
+
const notice = this.currentInputNotice();
|
|
1381
|
+
if (notice) out.push(clip(t.fg(notice.color, `ⓘ ${notice.text}`), width));
|
|
1382
|
+
} else {
|
|
1383
|
+
out.push(clip(t.fg("dim", "↑↓ prev/next · →/> attach · d done · v transcript · e evidence · r reply · esc back"), width));
|
|
1384
|
+
}
|
|
1385
|
+
return out;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
private renderEvidence(width: number): string[] {
|
|
1389
|
+
const t = this.theme;
|
|
1390
|
+
const row = this.selectedRow();
|
|
1391
|
+
if (!row) return [t.fg("muted", " (no session)")];
|
|
1392
|
+
const evidenceRes = this.deps.service.evidence?.(row.meta.id);
|
|
1393
|
+
const diagnosticsRes = this.deps.service.diagnostics?.(row.meta.id, { limit: 50 });
|
|
1394
|
+
const panel = buildEvidencePanel({
|
|
1395
|
+
row,
|
|
1396
|
+
evidence: evidenceRes?.ok ? evidenceRes.evidence : null,
|
|
1397
|
+
diagnostics: diagnosticsRes?.ok ? (diagnosticsRes.events ?? []) : [],
|
|
1398
|
+
paths: evidenceRes?.ok ? evidenceRes.paths : {},
|
|
1399
|
+
width,
|
|
1400
|
+
});
|
|
1401
|
+
const out: string[] = [];
|
|
1402
|
+
const capacity = Math.max(3, (this.tui.terminal?.rows ?? 24) - 5);
|
|
1403
|
+
out.push(...panel.slice(this.evidenceScrollTop, this.evidenceScrollTop + capacity).map((line: string) => clip(line, width)));
|
|
1404
|
+
out.push(t.fg("dim", "─".repeat(width)));
|
|
1405
|
+
out.push(clip(t.fg("dim", "←/< back · ↑↓ scroll · pgup/pgdn page · enter attach · r reply · v transcript · x clear diagnostics"), width));
|
|
1406
|
+
return out;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
private renderSession(width: number): string[] {
|
|
1410
|
+
const t = this.theme;
|
|
1411
|
+
const row = this.selectedRow();
|
|
1412
|
+
if (!row) return [t.fg("muted", " (no session)")];
|
|
1413
|
+
const st = rowState(row);
|
|
1414
|
+
const out: string[] = [];
|
|
1415
|
+
const session = loadSessionView(row.meta.sessionFile);
|
|
1416
|
+
out.push(`${stageFg(st, stateGlyph(st, row.alive, row.hostAlive))} ${t.fg("accent", t.bold(row.meta.name))} ${t.fg("muted", `[session view${row.alive ? " · live" : ""}${row.hostAlive ? " · hosted" : ""}]`)}`);
|
|
1417
|
+
out.push(t.fg("dim", ` ${row.meta.repoCwd}${row.meta.worktreeMode === "worktree" ? " (worktree)" : ""}`));
|
|
1418
|
+
if (session.header?.cwd && session.header.cwd !== row.meta.repoCwd) {
|
|
1419
|
+
out.push(t.fg("dim", ` session cwd: ${session.header.cwd}`));
|
|
1420
|
+
}
|
|
1421
|
+
out.push("");
|
|
1422
|
+
|
|
1423
|
+
const body: string[] = [];
|
|
1424
|
+
if (session.error) {
|
|
1425
|
+
body.push(t.fg("error", ` ${session.error}`));
|
|
1426
|
+
} else if (session.items.length === 0) {
|
|
1427
|
+
body.push(t.fg("muted", " Session transcript empty yet."));
|
|
1428
|
+
} else {
|
|
1429
|
+
for (const item of session.items) {
|
|
1430
|
+
body.push(this.renderSessionItemLabel(item, width));
|
|
1431
|
+
body.push(...wrap(` ${item.text}`, width, 10_000));
|
|
1432
|
+
body.push("");
|
|
1433
|
+
}
|
|
1434
|
+
if (body[body.length - 1] === "") body.pop();
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const capacity = Math.max(3, (this.tui.terminal?.rows ?? 24) - out.length - 3);
|
|
1438
|
+
out.push(...this.windowSession(body, capacity));
|
|
1439
|
+
out.push(t.fg("dim", "─".repeat(width)));
|
|
1440
|
+
out.push(clip(t.fg("dim", "←/< back · ↑↓ scroll · pgup/pgdn page · enter attach · d done · r reply · space peek"), width));
|
|
1441
|
+
return out;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
private renderSessionItemLabel(item: ReturnType<typeof loadSessionView>["items"][number], width: number): string {
|
|
1445
|
+
const t = this.theme;
|
|
1446
|
+
const label = ` ${item.label}`;
|
|
1447
|
+
switch (item.role) {
|
|
1448
|
+
case "user":
|
|
1449
|
+
return clip(t.fg("accent", label), width);
|
|
1450
|
+
case "assistant":
|
|
1451
|
+
return clip(t.fg("success", label), width);
|
|
1452
|
+
case "custom":
|
|
1453
|
+
return clip(t.fg("warning", label), width);
|
|
1454
|
+
default:
|
|
1455
|
+
return clip(t.fg("muted", label), width);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
private renderLaunch(width: number): string[] {
|
|
1460
|
+
const t = this.theme;
|
|
1461
|
+
const launch = this.launch;
|
|
1462
|
+
if (!launch) return [t.fg("muted", " Launch dialog unavailable.")];
|
|
1463
|
+
const boxWidth = clamp(Math.min(width - 4, 88), 48, Math.max(48, width - 2));
|
|
1464
|
+
const inner = Math.max(20, boxWidth - 4);
|
|
1465
|
+
const lines: string[] = [];
|
|
1466
|
+
lines.push(t.fg("accent", t.bold("Start session")));
|
|
1467
|
+
lines.push("");
|
|
1468
|
+
for (const line of wrap(`Prompt: ${this.input.trim()}`, inner, 3)) lines.push(line);
|
|
1469
|
+
lines.push("");
|
|
1470
|
+
lines.push(this.renderLaunchField(inner, 1, `cwd ${displayPath(launch.cwd)}`, launch.picker === null && launch.fieldIndex === 1));
|
|
1471
|
+
lines.push(this.renderLaunchField(inner, 2, `model ${launch.model ? formatLaunchModel(launch.model) : "default"}`, launch.picker === null && launch.fieldIndex === 2));
|
|
1472
|
+
lines.push(this.renderLaunchField(inner, 3, `thinking ${launch.thinking}`, launch.picker === null && launch.fieldIndex === 3));
|
|
1473
|
+
lines.push(this.renderLaunchField(inner, 4, `action ${launch.action === "attach" ? "start & attach" : "start in background"}`, launch.picker === null && launch.fieldIndex === 4));
|
|
1474
|
+
lines.push("");
|
|
1475
|
+
if (launch.picker === "cwd") {
|
|
1476
|
+
lines.push(t.fg("warning", `cwd› ${singleLineInput(launch.cwdQuery)}${cursor()}`));
|
|
1477
|
+
lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
|
|
1478
|
+
lines.push("");
|
|
1479
|
+
lines.push(t.fg("dim", "type to filter folders · enter choose · esc back"));
|
|
1480
|
+
} else if (launch.picker === "model") {
|
|
1481
|
+
lines.push(t.fg("warning", `model› ${singleLineInput(launch.modelQuery)}${cursor()}`));
|
|
1482
|
+
lines.push(...this.renderLaunchSuggestions(inner, launch.modelFiltered, launch.modelIndex, (choice) => `${formatLaunchModel(choice.model)}${choice.thinkingLevel ? ` · ${choice.thinkingLevel}` : ""}`));
|
|
1483
|
+
lines.push("");
|
|
1484
|
+
lines.push(t.fg("dim", `showing ${launch.scopeSource === "scoped" ? "scoped" : "available"} models · type to filter · enter choose · esc back`));
|
|
1485
|
+
} else if (launch.picker === "thinking") {
|
|
1486
|
+
lines.push(t.fg("warning", "thinking"));
|
|
1487
|
+
lines.push(...this.renderLaunchSuggestions(inner, launch.thinkingOptions, launch.thinkingIndex, (value) => value));
|
|
1488
|
+
lines.push("");
|
|
1489
|
+
lines.push(t.fg("dim", "↑↓ choose · enter select · esc back"));
|
|
1490
|
+
} else {
|
|
1491
|
+
lines.push(t.fg("dim", "↑↓ move · enter open/select/toggle · esc cancel"));
|
|
1492
|
+
}
|
|
1493
|
+
lines.push("");
|
|
1494
|
+
lines.push(this.renderLaunchButton(inner, launch.picker === null && launch.fieldIndex === 0));
|
|
1495
|
+
return renderCenteredBox(lines, width, this.tui.terminal?.rows ?? 24, t);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
private renderLaunchField(width: number, _index: number, text: string, selected: boolean): string {
|
|
1499
|
+
const prefix = selected ? this.theme.fg("accent", "› ") : this.theme.fg("dim", " ");
|
|
1500
|
+
const label = clip(text, Math.max(1, width - 2));
|
|
1501
|
+
return padTo(`${prefix}${selected ? this.theme.fg("accent", label) : label}`, width);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
private renderLaunchButton(width: number, selected: boolean): string {
|
|
1505
|
+
const label = ` ${this.launch?.action === "attach" ? "Start & attach" : "Start session"} `;
|
|
1506
|
+
const button = selected
|
|
1507
|
+
? `${ansiBg(56, 189, 248)}${ansiFg(15, 23, 42, this.theme.bold(label))}\x1b[49m\x1b[39m`
|
|
1508
|
+
: `${ansiBg(30, 41, 59)}${ansiFg(226, 232, 240, label)}\x1b[49m\x1b[39m`;
|
|
1509
|
+
const pad = Math.max(0, width - visibleWidth(label));
|
|
1510
|
+
return `${" ".repeat(pad)}${button}`;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
private renderLaunchSuggestions<T>(width: number, items: T[], index: number, renderItem: (item: T) => string): string[] {
|
|
1514
|
+
if (items.length === 0) return [this.theme.fg("muted", " no matches")];
|
|
1515
|
+
const start = Math.max(0, Math.min(index - 2, Math.max(0, items.length - 6)));
|
|
1516
|
+
const end = Math.min(items.length, start + 6);
|
|
1517
|
+
const out: string[] = [];
|
|
1518
|
+
for (let i = start; i < end; i++) {
|
|
1519
|
+
const selected = i === index;
|
|
1520
|
+
const prefix = selected ? this.theme.fg("accent", "→ ") : this.theme.fg("dim", " ");
|
|
1521
|
+
const body = clip(renderItem(items[i]), Math.max(1, width - 2));
|
|
1522
|
+
out.push(`${prefix}${selected ? this.theme.fg("accent", body) : body}`);
|
|
1523
|
+
}
|
|
1524
|
+
if (items.length > end) out.push(this.theme.fg("dim", ` ↓ ${items.length - end} more`));
|
|
1525
|
+
return out;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
private renderHelp(width: number): string[] {
|
|
1529
|
+
const t = this.theme;
|
|
1530
|
+
const rows: Array<[string, string]> = [
|
|
1531
|
+
["normal", "Dashboard owns keys; i enters insert mode"],
|
|
1532
|
+
["insert", "Editor owns text keys; / is literal, arrows move cursor"],
|
|
1533
|
+
["i", "Enter insert/compose mode; then / is literal for slash commands"],
|
|
1534
|
+
["m", "Enter multi-select mode from the board list"],
|
|
1535
|
+
["enter", "Normal/insert with draft: open start-session dialog; empty: attach/resume"],
|
|
1536
|
+
["shift+enter", "Insert a newline while in insert/reply"],
|
|
1537
|
+
["esc", "Insert → normal; Normal with draft clears; empty quits standalone dashboard"],
|
|
1538
|
+
["↑/↓", "Normal: move selection; Insert: move cursor"],
|
|
1539
|
+
["ctrl/alt+←/→", "Jump by word in insert mode"],
|
|
1540
|
+
["→ or >", "Attach to the selected real Pi session"],
|
|
1541
|
+
["d", "Confirm and mark selected inactive session done"],
|
|
1542
|
+
["space", "Peek when input is empty; in multi-select: toggle current row"],
|
|
1543
|
+
["e", "Open evidence / diagnostics panel for selected session"],
|
|
1544
|
+
["/", "Filter in normal mode; use i then / for slash commands"],
|
|
1545
|
+
["!", "Open node-pty diagnostics and fix steps"],
|
|
1546
|
+
["ctrl+n", "Open the new-session launch dialog (prompt pre-filled)"],
|
|
1547
|
+
["ctrl+r/t/s", "Rename · pin · stop selected"],
|
|
1548
|
+
["ctrl+x x2", "Delete selected session (quick double-press, no confirm)"],
|
|
1549
|
+
["X", "Delete all inactive sessions in selected state"],
|
|
1550
|
+
["v", "Open read-only transcript view"],
|
|
1551
|
+
["In select", "a select all visible · u clear · d move done · ctrl+x delete done · esc cancel"],
|
|
1552
|
+
["In peek", "→ attach · v transcript · e evidence · r reply · ↑↓ adjacent"],
|
|
1553
|
+
["In session", "← back · ↑↓ scroll · e evidence · enter attach · r reply"],
|
|
1554
|
+
["In evidence", "← back · ↑↓ scroll · r reply · v transcript · x clear diagnostics"],
|
|
1555
|
+
];
|
|
1556
|
+
const dialog = [t.fg("accent", t.bold("Hotkeys / keybindings")), ""];
|
|
1557
|
+
for (const [k, v] of rows) dialog.push(` ${t.fg("accent", k.padEnd(14))} ${t.fg("muted", v)}`);
|
|
1558
|
+
dialog.push("");
|
|
1559
|
+
dialog.push(t.fg("dim", ` press any key to return to ${this.helpReturnMode}`));
|
|
1560
|
+
return renderCenteredBox(dialog, width, this.tui.terminal?.rows ?? 24, t);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
private renderPtyHelp(width: number, health: PtyHealth): string[] {
|
|
1564
|
+
const t = this.theme;
|
|
1565
|
+
const dialog: string[] = [t.fg("accent", t.bold("node-pty diagnostics")), ""];
|
|
1566
|
+
const wrapWidth = Math.max(32, width - 16);
|
|
1567
|
+
if (health.ok) {
|
|
1568
|
+
dialog.push(t.fg("success", " Status: healthy"));
|
|
1569
|
+
dialog.push(t.fg("muted", ` Live hosts: ${health.liveHosts} · stale hosts: ${health.staleHosts}`));
|
|
1570
|
+
dialog.push("");
|
|
1571
|
+
dialog.push(t.fg("dim", ` press any key to return to ${this.ptyHelpReturnMode}`));
|
|
1572
|
+
return renderCenteredBox(dialog, width, this.tui.terminal?.rows ?? 24, t);
|
|
1573
|
+
}
|
|
1574
|
+
const issue = health.issue;
|
|
1575
|
+
dialog.push(t.fg("warning", ` Status: unavailable · ${issue?.statusLabel ?? "unknown cause"}`));
|
|
1576
|
+
if (issue?.summary) {
|
|
1577
|
+
dialog.push("");
|
|
1578
|
+
dialog.push(...wrap(` ${issue.summary}`, wrapWidth, 8));
|
|
1579
|
+
}
|
|
1580
|
+
if (issue?.fixHint) dialog.push(t.fg("warning", ` Fix: ${issue.fixHint}`));
|
|
1581
|
+
if (issue?.steps?.length) {
|
|
1582
|
+
dialog.push("");
|
|
1583
|
+
dialog.push(t.fg("accent", " Suggested steps"));
|
|
1584
|
+
for (const step of issue.steps) {
|
|
1585
|
+
if (!step.trim()) continue;
|
|
1586
|
+
if (step.endsWith(":")) dialog.push(t.fg("muted", ` ${step}`));
|
|
1587
|
+
else dialog.push(...wrap(` ${step}`, wrapWidth, 10));
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
if (issue?.rawReason || health.reason) {
|
|
1591
|
+
dialog.push("");
|
|
1592
|
+
dialog.push(t.fg("accent", " Raw reason"));
|
|
1593
|
+
dialog.push(...wrap(` ${issue?.rawReason ?? health.reason ?? "unknown"}`, wrapWidth, 8));
|
|
1594
|
+
}
|
|
1595
|
+
dialog.push("");
|
|
1596
|
+
dialog.push(t.fg("dim", ` press any key to return to ${this.ptyHelpReturnMode}`));
|
|
1597
|
+
return renderCenteredBox(dialog, width, this.tui.terminal?.rows ?? 24, t);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/** Keep the selected row visible within `capacity` lines, with scroll markers. */
|
|
1601
|
+
private windowBody(body: string[], capacity: number): { lines: string[] } {
|
|
1602
|
+
if (body.length <= capacity) {
|
|
1603
|
+
this.scrollTop = 0;
|
|
1604
|
+
return { lines: body };
|
|
1605
|
+
}
|
|
1606
|
+
// Find the line index of the selected row (best effort: match its rendered prefix).
|
|
1607
|
+
let selLine = 0;
|
|
1608
|
+
if (this.selectedId) {
|
|
1609
|
+
const idx = body.findIndex((l) => l.includes("›"));
|
|
1610
|
+
selLine = idx >= 0 ? idx : 0;
|
|
1611
|
+
}
|
|
1612
|
+
if (selLine < this.scrollTop) this.scrollTop = selLine;
|
|
1613
|
+
if (selLine >= this.scrollTop + capacity - 1) this.scrollTop = selLine - capacity + 2;
|
|
1614
|
+
this.scrollTop = Math.max(0, Math.min(this.scrollTop, body.length - capacity));
|
|
1615
|
+
const slice = body.slice(this.scrollTop, this.scrollTop + capacity);
|
|
1616
|
+
const more = body.length - this.scrollTop - capacity;
|
|
1617
|
+
if (this.scrollTop > 0) slice[0] = this.theme.fg("dim", ` ↑ ${this.scrollTop} more`);
|
|
1618
|
+
if (more > 0) slice[slice.length - 1] = this.theme.fg("dim", ` ↓ ${more} more`);
|
|
1619
|
+
return { lines: slice };
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
private windowSession(body: string[], capacity: number): string[] {
|
|
1623
|
+
if (body.length <= capacity) {
|
|
1624
|
+
this.sessionScrollTop = 0;
|
|
1625
|
+
return body;
|
|
1626
|
+
}
|
|
1627
|
+
this.sessionScrollTop = Math.max(0, Math.min(this.sessionScrollTop, body.length - capacity));
|
|
1628
|
+
const slice = body.slice(this.sessionScrollTop, this.sessionScrollTop + capacity);
|
|
1629
|
+
const more = body.length - this.sessionScrollTop - capacity;
|
|
1630
|
+
if (this.sessionScrollTop > 0) slice[0] = this.theme.fg("dim", ` ↑ ${this.sessionScrollTop} more`);
|
|
1631
|
+
if (more > 0) slice[slice.length - 1] = this.theme.fg("dim", ` ↓ ${more} more`);
|
|
1632
|
+
return slice;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// ---- helpers --------------------------------------------------------------
|
|
1637
|
+
|
|
1638
|
+
function sameLaunchModel(a: LaunchModel | null | undefined, b: LaunchModel | null | undefined): boolean {
|
|
1639
|
+
return Boolean(a && b && a.provider === b.provider && a.id === b.id);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function formatLaunchModel(model: LaunchModel): string {
|
|
1643
|
+
return `${model.provider}/${model.id}`;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function filterLaunchChoices(choices: LaunchChoice[], query: string): LaunchChoice[] {
|
|
1647
|
+
const q = query.trim().toLowerCase();
|
|
1648
|
+
if (!q) return choices;
|
|
1649
|
+
return choices.filter((choice) => {
|
|
1650
|
+
const hay = `${choice.model.provider} ${choice.model.id} ${choice.model.name ?? ""} ${choice.model.provider}/${choice.model.id}`.toLowerCase();
|
|
1651
|
+
return hay.includes(q);
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function findLaunchModelByRef(models: LaunchModel[], ref: string): LaunchModel | null {
|
|
1656
|
+
const target = String(ref || "").trim().toLowerCase();
|
|
1657
|
+
if (!target) return null;
|
|
1658
|
+
return models.find((model) => `${model.provider}/${model.id}`.toLowerCase() === target) ?? null;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function stripBracketedPaste(data: string): string {
|
|
1662
|
+
return data.replace(/\x1b\[200~|\x1b\[201~/g, "");
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function homeLaunchRoot(fallback: string): string {
|
|
1666
|
+
return process.env.HOME || process.env.USERPROFILE ? "~" : fallback;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
function renderCenteredBox(lines: string[], width: number, height: number, theme: ThemeLike): string[] {
|
|
1670
|
+
const innerWidth = Math.max(20, Math.min(width - 6, Math.max(...lines.map((line) => visibleWidth(line)), 20)));
|
|
1671
|
+
const boxWidth = Math.min(width, innerWidth + 4);
|
|
1672
|
+
const left = Math.max(0, Math.floor((width - boxWidth) / 2));
|
|
1673
|
+
const box = [
|
|
1674
|
+
`${theme.fg("borderMuted", "┌")}${theme.fg("borderMuted", "─".repeat(Math.max(0, boxWidth - 2)))}${theme.fg("borderMuted", "┐")}`,
|
|
1675
|
+
...lines.map((line) => `${theme.fg("borderMuted", "│")} ${padTo(clip(line, boxWidth - 4), boxWidth - 4)} ${theme.fg("borderMuted", "│")}`),
|
|
1676
|
+
`${theme.fg("borderMuted", "└")}${theme.fg("borderMuted", "─".repeat(Math.max(0, boxWidth - 2)))}${theme.fg("borderMuted", "┘")}`,
|
|
1677
|
+
];
|
|
1678
|
+
const top = Math.max(0, Math.floor((height - box.length) / 2) - 1);
|
|
1679
|
+
const out: string[] = [];
|
|
1680
|
+
for (let i = 0; i < top; i++) out.push("");
|
|
1681
|
+
for (const line of box) out.push(`${" ".repeat(left)}${line}`);
|
|
1682
|
+
return out;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const DELETE_DOUBLE_PRESS_MS = 500;
|
|
1686
|
+
|
|
1687
|
+
const AGENTBOARD_VERSION = readAgentBoardVersion();
|
|
1688
|
+
function readAgentBoardVersion(): string {
|
|
1689
|
+
try {
|
|
1690
|
+
const raw = readFileSync(new URL("../../package.json", import.meta.url), "utf8");
|
|
1691
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
1692
|
+
return pkg.version ? `v${pkg.version}` : "dev";
|
|
1693
|
+
} catch {
|
|
1694
|
+
return "dev";
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
// ANSI rendering of /Users/rutvik/Downloads/pi-logo-on-dark.svg.
|
|
1698
|
+
// The SVG is a 4x4 grid mark: P-shaped left form plus a separate lower-right stem.
|
|
1699
|
+
const PI_ICON = [
|
|
1700
|
+
" ▐▛███▜▌",
|
|
1701
|
+
"▝▜█████▛▘",
|
|
1702
|
+
] as const;
|
|
1703
|
+
const PI_ICON_WIDTH = Math.max(...PI_ICON.map((line) => visibleWidth(line)));
|
|
1704
|
+
const HEADER_LEFT_PADDING = 2;
|
|
1705
|
+
const HEADER_TEXT_GAP = 4;
|
|
1706
|
+
const HEADER_TOP_PADDING = 0;
|
|
1707
|
+
const HEADER_BOTTOM_PADDING = 1;
|
|
1708
|
+
const AGENTBOARD_HEADER_MIN_WIDTH = HEADER_LEFT_PADDING + PI_ICON_WIDTH + HEADER_TEXT_GAP + 24;
|
|
1709
|
+
|
|
1710
|
+
type HeaderCounts = { needs: number; working: number; completed: number; unread: number };
|
|
1711
|
+
|
|
1712
|
+
function renderAgentboardHeader(
|
|
1713
|
+
width: number,
|
|
1714
|
+
theme: ThemeLike,
|
|
1715
|
+
counts: HeaderCounts,
|
|
1716
|
+
ptyHealth: PtyHealth,
|
|
1717
|
+
filterQuery: string,
|
|
1718
|
+
): string[] {
|
|
1719
|
+
if (width < AGENTBOARD_HEADER_MIN_WIDTH) {
|
|
1720
|
+
const raw = [
|
|
1721
|
+
...blankLines(HEADER_TOP_PADDING),
|
|
1722
|
+
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),
|
|
1723
|
+
clip(headerStageSummary(theme, counts, filterQuery, true), width),
|
|
1724
|
+
...blankLines(HEADER_BOTTOM_PADDING),
|
|
1725
|
+
];
|
|
1726
|
+
return raw.map((line) => headerBgLine(line, width));
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const textRows = headerTextRows(theme, counts, ptyHealth, filterQuery);
|
|
1730
|
+
const textStart = Math.max(0, Math.round((PI_ICON.length - textRows.length) / 2));
|
|
1731
|
+
const raw = blankLines(HEADER_TOP_PADDING);
|
|
1732
|
+
raw.push(
|
|
1733
|
+
...PI_ICON.map((iconLine, i) =>
|
|
1734
|
+
clip(`${" ".repeat(HEADER_LEFT_PADDING)}${renderPiIconLine(theme, iconLine)}${" ".repeat(HEADER_TEXT_GAP)}${textRows[i - textStart] ?? ""}`, width),
|
|
1735
|
+
),
|
|
1736
|
+
);
|
|
1737
|
+
raw.push(...blankLines(HEADER_BOTTOM_PADDING));
|
|
1738
|
+
return raw.map((line) => headerBgLine(line, width));
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function headerTextRows(
|
|
1742
|
+
theme: ThemeLike,
|
|
1743
|
+
counts: HeaderCounts,
|
|
1744
|
+
ptyHealth: PtyHealth,
|
|
1745
|
+
filterQuery: string,
|
|
1746
|
+
): string[] {
|
|
1747
|
+
const title = `${theme.fg("accent", theme.bold("AgentBoard"))} ${theme.fg("muted", AGENTBOARD_VERSION)} ${theme.fg("dim", "·")} ${renderPtyHealth(theme, ptyHealth)}`;
|
|
1748
|
+
return [
|
|
1749
|
+
title,
|
|
1750
|
+
headerStageSummary(theme, counts, filterQuery, false),
|
|
1751
|
+
];
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function headerStageSummary(theme: ThemeLike, counts: HeaderCounts, filterQuery: string, compact: boolean): string {
|
|
1755
|
+
const joiner = theme.fg("dim", " · ");
|
|
1756
|
+
const parts = [
|
|
1757
|
+
headerStagePart(theme, "needs_input", counts.needs, compact ? "awaiting" : "awaiting input"),
|
|
1758
|
+
headerStagePart(theme, "working", counts.working, "running"),
|
|
1759
|
+
headerUnreadPart(theme, counts.unread),
|
|
1760
|
+
headerStagePart(theme, "completed", counts.completed, "done"),
|
|
1761
|
+
];
|
|
1762
|
+
if (filterQuery) parts.push(theme.fg("warning", `filter:${filterQuery}`));
|
|
1763
|
+
return parts.join(joiner);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function headerUnreadPart(theme: ThemeLike, count: number): string {
|
|
1767
|
+
return `${theme.fg(count > 0 ? "accent" : "dim", `•${count}`)} ${theme.fg("dim", "unread")}`;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function headerStagePart(theme: ThemeLike, state: keyof typeof GROUP_LABELS, count: number, label: string): string {
|
|
1771
|
+
return `${stageFg(state, String(count))} ${theme.fg("dim", label)}`;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function renderPtyHealth(theme: ThemeLike, health: PtyHealth): string {
|
|
1775
|
+
const text = ptyHealthText(health);
|
|
1776
|
+
if (!health.ok) return theme.fg("error", text);
|
|
1777
|
+
if (health.staleHosts > 0) return theme.fg("warning", text);
|
|
1778
|
+
return theme.fg("success", text);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function renderPtyWarningBanner(_theme: ThemeLike, issue: PtyIssue, width: number): string[] {
|
|
1782
|
+
return renderFlashBanner(
|
|
1783
|
+
{ text: `${issue.summary} Fix: ${issue.fixHint} Press ! for exact steps.`, level: "warn" },
|
|
1784
|
+
width,
|
|
1785
|
+
{ bottomGap: true },
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function ptyHealthText(health: PtyHealth): string {
|
|
1790
|
+
if (!health.ok) return `node-pty unavailable${health.issue?.statusLabel ? ` · ${health.issue.statusLabel}` : health.reason ? ` (${compactPtyReason(health.reason)})` : ""}`;
|
|
1791
|
+
if (health.staleHosts > 0) return `node-pty degraded · stale:${health.staleHosts}`;
|
|
1792
|
+
return health.liveHosts > 0 ? `node-pty healthy · hosts:${health.liveHosts}` : "node-pty healthy";
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
function compactPtyReason(reason: string): string {
|
|
1796
|
+
const cleaned = String(reason || "").replace(/\s+/g, " ").trim();
|
|
1797
|
+
if (!cleaned) return "unknown";
|
|
1798
|
+
const first = firstSentence(cleaned);
|
|
1799
|
+
return truncate(first.length >= 12 ? first : cleaned, 44);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
const STAGE_RGB = {
|
|
1803
|
+
queued: [148, 163, 184],
|
|
1804
|
+
working: [56, 189, 248],
|
|
1805
|
+
needs_input: [245, 158, 11],
|
|
1806
|
+
idle: [129, 140, 248],
|
|
1807
|
+
completed: [34, 197, 94],
|
|
1808
|
+
failed: [248, 113, 113],
|
|
1809
|
+
stopped: [100, 116, 139],
|
|
1810
|
+
} as const satisfies Record<keyof typeof GROUP_LABELS, readonly [number, number, number]>;
|
|
1811
|
+
|
|
1812
|
+
function stageFg(state: keyof typeof GROUP_LABELS, text: string): string {
|
|
1813
|
+
const [r, g, b] = STAGE_RGB[state];
|
|
1814
|
+
return ansiFg(r, g, b, text);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function renderPiIconLine(theme: ThemeLike, line: string): string {
|
|
1818
|
+
return theme.fg("accent", line);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function ansiFg(r: number, g: number, b: number, text: string): string {
|
|
1822
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function ansiBg(r: number, g: number, b: number): string {
|
|
1826
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
function headerBgLine(content: string, width: number): string {
|
|
1830
|
+
const clipped = clip(content, width);
|
|
1831
|
+
const rest = Math.max(0, width - visibleWidth(clipped));
|
|
1832
|
+
return `${clipped}${" ".repeat(rest)}`;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
function blankLines(count: number): string[] {
|
|
1836
|
+
return Array.from({ length: count }, () => "");
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
function isPrintable(data: string): boolean {
|
|
1840
|
+
if (data.length === 0) return false;
|
|
1841
|
+
// Accept multi-char paste of printable text; reject control/escape sequences.
|
|
1842
|
+
return [...data].every((ch) => ch >= " " && ch !== "\x7f");
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function isBracketedPaste(data: string): boolean {
|
|
1846
|
+
return data.includes("\x1b[200~");
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function cursor(): string {
|
|
1850
|
+
return "\x1b[7m \x1b[27m";
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function stripInactiveEditorCursor(line: string): string {
|
|
1854
|
+
return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/, (_match, content: string) => (content === " " ? "" : content));
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function singleLineInput(text: string): string {
|
|
1858
|
+
return text.replace(/[\r\n]+/g, " ");
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function clip(line: string, width: number): string {
|
|
1862
|
+
return truncateToWidth(line, width, "");
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function padTo(line: string, width: number): string {
|
|
1866
|
+
const w = visibleWidth(line);
|
|
1867
|
+
return w >= width ? line : line + " ".repeat(width - w);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function wrap(text: string, width: number, max = 6): string[] {
|
|
1871
|
+
const words = text.split(/\s+/);
|
|
1872
|
+
const lines: string[] = [];
|
|
1873
|
+
let cur = "";
|
|
1874
|
+
for (const word of words) {
|
|
1875
|
+
if (visibleWidth(cur) + visibleWidth(word) + 1 > width) {
|
|
1876
|
+
lines.push(cur);
|
|
1877
|
+
cur = ` ${word}`;
|
|
1878
|
+
} else {
|
|
1879
|
+
cur = cur ? `${cur} ${word}` : word;
|
|
1880
|
+
}
|
|
1881
|
+
if (lines.length >= max) break;
|
|
1882
|
+
}
|
|
1883
|
+
if (cur && lines.length < max) lines.push(cur);
|
|
1884
|
+
return lines.map((l) => clip(l, width));
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function isAgentBusy(row: Row): boolean {
|
|
1888
|
+
const st = row.state?.semanticState;
|
|
1889
|
+
const waitingOnTool = Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
|
|
1890
|
+
return Boolean(row.alive && (st === "queued" || st === "working" || waitingOnTool));
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function completedSummary(summary: string, _latestAssistantPreview: string): string {
|
|
1894
|
+
if (!isGenericStatusText(summary)) return compactCompletedSummary(summary);
|
|
1895
|
+
return "Done";
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function compactCompletedSummary(text: string): string {
|
|
1899
|
+
const cleaned = String(text || "").replace(/\s+/g, " ").trim();
|
|
1900
|
+
if (!cleaned) return "Done";
|
|
1901
|
+
const first = firstSentence(cleaned);
|
|
1902
|
+
return truncate(first.length >= 12 ? first : cleaned, 80);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function displayPath(path: string): string {
|
|
1906
|
+
const home = process.env.HOME;
|
|
1907
|
+
if (home && path.startsWith(home)) return `~${path.slice(home.length)}` || "~";
|
|
1908
|
+
return path;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function clamp(n: number, min: number, max: number): number {
|
|
1912
|
+
return Math.max(min, Math.min(max, n));
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function editorTheme(theme: ThemeLike): EditorTheme {
|
|
1916
|
+
return {
|
|
1917
|
+
borderColor: (text: string) => theme.fg("borderMuted", text),
|
|
1918
|
+
selectList: {
|
|
1919
|
+
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
1920
|
+
selectedText: (text: string) => theme.fg("accent", text),
|
|
1921
|
+
description: (text: string) => theme.fg("muted", text),
|
|
1922
|
+
scrollInfo: (text: string) => theme.fg("muted", text),
|
|
1923
|
+
noMatch: (text: string) => theme.fg("muted", text),
|
|
1924
|
+
},
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function renderFlashBanner(flash: { text: string; level: FlashLevel }, width: number, opts: { bottomGap?: boolean } = {}): string[] {
|
|
1929
|
+
const style = flashStyle(flash.level);
|
|
1930
|
+
const contentWidth = Math.max(1, width - 1);
|
|
1931
|
+
const content = clip(` ${style.icon} ${flash.text} `, contentWidth);
|
|
1932
|
+
const line = clip(`${ansiFg(...style.accent, "▌")}${ansiBg(...style.bg)}${ansiFg(...style.fg, content)}\x1b[49m`, width);
|
|
1933
|
+
return opts.bottomGap ? [line, ""] : [line];
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
type FlashStyle = {
|
|
1937
|
+
icon: string;
|
|
1938
|
+
accent: readonly [number, number, number];
|
|
1939
|
+
bg: readonly [number, number, number];
|
|
1940
|
+
fg: readonly [number, number, number];
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
function flashStyle(level: FlashLevel): FlashStyle {
|
|
1944
|
+
switch (level) {
|
|
1945
|
+
case "warn":
|
|
1946
|
+
return { icon: "!", accent: [245, 158, 11], bg: [39, 31, 14], fg: [253, 230, 138] };
|
|
1947
|
+
case "error":
|
|
1948
|
+
return { icon: "×", accent: [248, 113, 113], bg: [45, 18, 23], fg: [254, 202, 202] };
|
|
1949
|
+
default:
|
|
1950
|
+
return { icon: "i", accent: [56, 189, 248], bg: [8, 31, 49], fg: [186, 230, 253] };
|
|
1951
|
+
}
|
|
1952
|
+
}
|