@abelxiaoxing/cadence 2.0.2 → 2.0.3
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/README.md +45 -5
- package/docs/reuse-and-distribution.md +1 -1
- package/docs/service-management.md +15 -3
- package/package.json +5 -2
- package/src/agent-panel-cadence.ts +651 -0
- package/src/agent-panel-model.ts +521 -0
- package/src/agent-panel.ts +474 -0
- package/src/index.ts +12 -4
- package/src/native-client.ts +125 -14
- package/src/service/private-files.ts +18 -10
- package/src/service-cli.mjs +80 -26
- package/src/service-host.mjs +13 -11
- package/src/task-shell.ts +11 -8
- package/web/dist/assets/cadence.css +1 -1
- package/web/dist/assets/cadence.js +7 -7
- package/web/dist/manifest.json +6 -6
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/** Background-agent panel model. Pure: no Pi host, terminal or timer access.
|
|
2
|
+
* The panel only observes these bus events; producers own their agents, and
|
|
3
|
+
* nothing here can start, cancel or keep one alive. */
|
|
4
|
+
|
|
5
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
export const SUBAGENT_EVENTS = {
|
|
8
|
+
start: "subagent:start",
|
|
9
|
+
message: "subagent:message",
|
|
10
|
+
activity: "subagent:activity",
|
|
11
|
+
stats: "subagent:stats",
|
|
12
|
+
end: "subagent:end",
|
|
13
|
+
} as const;
|
|
14
|
+
/** Panel → producers: the agent whose transcript is shown (`id: null` when
|
|
15
|
+
* none), so a producer can load messages for that one agent only. */
|
|
16
|
+
export const SUBAGENT_VIEW = "subagent:view";
|
|
17
|
+
|
|
18
|
+
export type SubagentStatus = "running" | "done" | "error" | "cancelled";
|
|
19
|
+
|
|
20
|
+
export interface SubagentState {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
startedAt: number;
|
|
25
|
+
/** Producer passed `startedAt: null`: the run began before it was observed. */
|
|
26
|
+
untimed?: boolean;
|
|
27
|
+
endedAt?: number;
|
|
28
|
+
status: SubagentStatus;
|
|
29
|
+
activity: string;
|
|
30
|
+
/** Absent until a producer reports usage; not every producer meters it. */
|
|
31
|
+
tokensIn?: number;
|
|
32
|
+
tokensOut?: number;
|
|
33
|
+
error?: string;
|
|
34
|
+
/** Messages in arrival order (same shape as the main session's). */
|
|
35
|
+
messages: object[];
|
|
36
|
+
/** The last message is a streaming snapshot the next message replaces. */
|
|
37
|
+
streaming: boolean;
|
|
38
|
+
/** Earlier messages dropped to keep the retained view bounded. */
|
|
39
|
+
omitted: number;
|
|
40
|
+
/** Bumped by every message event, so views rebuild only on change. */
|
|
41
|
+
revision: number;
|
|
42
|
+
/** Time of the last event, for choosing the most recently active rows. */
|
|
43
|
+
activeAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Retained messages per agent; older ones are counted, not kept. */
|
|
47
|
+
export const MAX_MESSAGES = 2000;
|
|
48
|
+
const ENDED = new Set(["done", "error", "cancelled"]);
|
|
49
|
+
|
|
50
|
+
const record = (v: unknown): Record<string, unknown> | undefined =>
|
|
51
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
52
|
+
? (v as Record<string, unknown>)
|
|
53
|
+
: undefined;
|
|
54
|
+
/** One terminal-safe line: controls and line breaks collapse to spaces. */
|
|
55
|
+
export function oneLine(v: string): string {
|
|
56
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip terminal controls from producer text
|
|
57
|
+
return v.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
|
|
58
|
+
}
|
|
59
|
+
const line = (v: unknown, max: number) =>
|
|
60
|
+
typeof v === "string" ? oneLine(v).slice(0, max) : undefined;
|
|
61
|
+
const count = (v: unknown) =>
|
|
62
|
+
typeof v === "number" && Number.isFinite(v) && v >= 0
|
|
63
|
+
? Math.floor(v)
|
|
64
|
+
: undefined;
|
|
65
|
+
|
|
66
|
+
/** Map<id, SubagentState> fed only by bus events. Malformed, unknown-agent,
|
|
67
|
+
* duplicate-start and post-end activity events are ignored. */
|
|
68
|
+
export class SubagentStore {
|
|
69
|
+
private readonly agents = new Map<string, SubagentState>();
|
|
70
|
+
private readonly listeners = new Set<() => void>();
|
|
71
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
72
|
+
|
|
73
|
+
get size(): number {
|
|
74
|
+
return this.agents.size;
|
|
75
|
+
}
|
|
76
|
+
get(id: string): SubagentState | undefined {
|
|
77
|
+
return this.agents.get(id);
|
|
78
|
+
}
|
|
79
|
+
/** Start order, which stays stable while rows update. */
|
|
80
|
+
ordered(): SubagentState[] {
|
|
81
|
+
return [...this.agents.values()];
|
|
82
|
+
}
|
|
83
|
+
running(): number {
|
|
84
|
+
let n = 0;
|
|
85
|
+
for (const a of this.agents.values()) if (a.status === "running") n++;
|
|
86
|
+
return n;
|
|
87
|
+
}
|
|
88
|
+
subscribe(listener: () => void): () => void {
|
|
89
|
+
this.listeners.add(listener);
|
|
90
|
+
return () => this.listeners.delete(listener);
|
|
91
|
+
}
|
|
92
|
+
apply(channel: string, data: unknown): boolean {
|
|
93
|
+
const changed = this.reduce(channel, data);
|
|
94
|
+
if (changed) for (const listener of this.listeners) listener();
|
|
95
|
+
return changed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private reduce(channel: string, data: unknown): boolean {
|
|
99
|
+
const o = record(data);
|
|
100
|
+
const id = o && line(o.id, 256);
|
|
101
|
+
if (!o || !id) return false;
|
|
102
|
+
const now = this.now();
|
|
103
|
+
if (channel === SUBAGENT_EVENTS.start) {
|
|
104
|
+
if (this.agents.has(id)) return false;
|
|
105
|
+
this.agents.set(id, {
|
|
106
|
+
id,
|
|
107
|
+
label: line(o.label, 512) || id,
|
|
108
|
+
kind: line(o.kind, 32) || "agent",
|
|
109
|
+
startedAt: count(o.startedAt) ?? now,
|
|
110
|
+
...(o.startedAt === null ? { untimed: true } : {}),
|
|
111
|
+
status: "running",
|
|
112
|
+
activity: "",
|
|
113
|
+
messages: [],
|
|
114
|
+
streaming: false,
|
|
115
|
+
omitted: 0,
|
|
116
|
+
revision: 0,
|
|
117
|
+
activeAt: now,
|
|
118
|
+
});
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
const agent = this.agents.get(id);
|
|
122
|
+
if (!agent) return false;
|
|
123
|
+
switch (channel) {
|
|
124
|
+
case SUBAGENT_EVENTS.message: {
|
|
125
|
+
const message = record(o.message);
|
|
126
|
+
if (!message) return false;
|
|
127
|
+
if (agent.streaming)
|
|
128
|
+
agent.messages[agent.messages.length - 1] = message;
|
|
129
|
+
else agent.messages.push(message);
|
|
130
|
+
agent.streaming = o.streaming === true;
|
|
131
|
+
agent.revision++;
|
|
132
|
+
const excess = agent.messages.length - MAX_MESSAGES;
|
|
133
|
+
if (excess > 0) {
|
|
134
|
+
agent.messages.splice(0, excess);
|
|
135
|
+
agent.omitted += excess;
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case SUBAGENT_EVENTS.activity: {
|
|
140
|
+
if (agent.status !== "running") return false;
|
|
141
|
+
const activity = line(o.activity, 512);
|
|
142
|
+
if (activity === undefined) return false;
|
|
143
|
+
agent.activity = activity;
|
|
144
|
+
this.stats(agent, o);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case SUBAGENT_EVENTS.stats:
|
|
148
|
+
if (!this.stats(agent, o)) return false;
|
|
149
|
+
break;
|
|
150
|
+
case SUBAGENT_EVENTS.end: {
|
|
151
|
+
if (agent.status !== "running" || !ENDED.has(o.status as string))
|
|
152
|
+
return false;
|
|
153
|
+
agent.status = o.status as SubagentStatus;
|
|
154
|
+
agent.endedAt = Math.max(agent.startedAt, count(o.endedAt) ?? now);
|
|
155
|
+
const error = line(o.error, 512);
|
|
156
|
+
if (error) agent.error = error;
|
|
157
|
+
if (agent.streaming) agent.revision++;
|
|
158
|
+
agent.streaming = false;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
default:
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
agent.activeAt = now;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
private stats(agent: SubagentState, o: Record<string, unknown>): boolean {
|
|
168
|
+
const tokensIn = count(o.tokensIn),
|
|
169
|
+
tokensOut = count(o.tokensOut);
|
|
170
|
+
if (tokensIn !== undefined) agent.tokensIn = tokensIn;
|
|
171
|
+
if (tokensOut !== undefined) agent.tokensOut = tokensOut;
|
|
172
|
+
return tokensIn !== undefined || tokensOut !== undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** "43s", "4m 43s", "1h 02m". */
|
|
177
|
+
export function formatElapsed(ms: number): string {
|
|
178
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
179
|
+
if (s < 60) return `${s}s`;
|
|
180
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
181
|
+
const m = Math.floor((s % 3600) / 60);
|
|
182
|
+
return `${Math.floor(s / 3600)}h ${String(m).padStart(2, "0")}m`;
|
|
183
|
+
}
|
|
184
|
+
/** "999", "110.9k", "1.2M"; a value that rounds to 1000k is shown as M. */
|
|
185
|
+
export function formatTokens(n: number): string {
|
|
186
|
+
if (n < 1000) return String(n);
|
|
187
|
+
const k = Math.round(n / 100) / 10;
|
|
188
|
+
if (k < 1000) return `${k.toFixed(1)}k`;
|
|
189
|
+
return `${(Math.round(n / 100000) / 10).toFixed(1)}M`;
|
|
190
|
+
}
|
|
191
|
+
export function elapsedOf(agent: SubagentState, now: number): number {
|
|
192
|
+
return (agent.endedAt ?? now) - agent.startedAt;
|
|
193
|
+
}
|
|
194
|
+
/** Elapsed column text: "—" when the start was never observed. */
|
|
195
|
+
export function elapsedText(agent: SubagentState, now: number): string {
|
|
196
|
+
return agent.untimed ? "—" : formatElapsed(elapsedOf(agent, now));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type PaintRole =
|
|
200
|
+
| "accent"
|
|
201
|
+
| "success"
|
|
202
|
+
| "error"
|
|
203
|
+
| "muted"
|
|
204
|
+
| "dim"
|
|
205
|
+
| "selected";
|
|
206
|
+
/** Theme seam; tests pass identity. `selected` is reverse video. */
|
|
207
|
+
export type Paint = (role: PaintRole, text: string) => string;
|
|
208
|
+
|
|
209
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
210
|
+
const SYMBOL: Record<
|
|
211
|
+
Exclude<SubagentStatus, "running">,
|
|
212
|
+
[string, PaintRole]
|
|
213
|
+
> = {
|
|
214
|
+
done: ["✔", "success"],
|
|
215
|
+
error: ["✘", "error"],
|
|
216
|
+
cancelled: ["○", "muted"],
|
|
217
|
+
};
|
|
218
|
+
export function statusSymbol(
|
|
219
|
+
agent: SubagentState,
|
|
220
|
+
frame: number,
|
|
221
|
+
): [string, PaintRole] {
|
|
222
|
+
return agent.status === "running"
|
|
223
|
+
? [SPINNER[frame % SPINNER.length], "accent"]
|
|
224
|
+
: SYMBOL[agent.status];
|
|
225
|
+
}
|
|
226
|
+
/** `↓in [↑out]`, or "" when the producer never reported usage. */
|
|
227
|
+
export function usage(agent: SubagentState): string {
|
|
228
|
+
const input =
|
|
229
|
+
agent.tokensIn === undefined ? "" : `↓${formatTokens(agent.tokensIn)}`;
|
|
230
|
+
const out =
|
|
231
|
+
agent.tokensOut === undefined ? "" : `↑${formatTokens(agent.tokensOut)}`;
|
|
232
|
+
return [input, out].filter(Boolean).join(" ");
|
|
233
|
+
}
|
|
234
|
+
/** Elapsed plus usage when known: "4m 12s · ↓1.2M" or "4m 12s". */
|
|
235
|
+
export function factsText(agent: SubagentState, now: number): string {
|
|
236
|
+
const used = usage(agent);
|
|
237
|
+
return `${elapsedText(agent, now)}${used ? ` · ${used}` : ""}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The `max` most recently active agents plus any pinned ones, shown in
|
|
241
|
+
* stable start order, and how many others are hidden. */
|
|
242
|
+
export function visibleAgents(
|
|
243
|
+
agents: SubagentState[],
|
|
244
|
+
max: number,
|
|
245
|
+
pinned: (string | undefined)[] = [],
|
|
246
|
+
): { rows: SubagentState[]; hidden: number } {
|
|
247
|
+
if (agents.length <= max) return { rows: agents, hidden: 0 };
|
|
248
|
+
const recent = [...agents].sort((a, b) => b.activeAt - a.activeAt);
|
|
249
|
+
const keep = new Set(recent.slice(0, max).map((a) => a.id));
|
|
250
|
+
for (const id of pinned) {
|
|
251
|
+
if (!id || keep.has(id) || !agents.some((a) => a.id === id)) continue;
|
|
252
|
+
const drop = [...keep]
|
|
253
|
+
.reverse()
|
|
254
|
+
.find((k) => !pinned.includes(k) && k !== id);
|
|
255
|
+
if (drop) keep.delete(drop);
|
|
256
|
+
keep.add(id);
|
|
257
|
+
}
|
|
258
|
+
const rows = agents.filter((a) => keep.has(a.id));
|
|
259
|
+
return { rows, hidden: agents.length - rows.length };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface PanelFrame {
|
|
263
|
+
agents: SubagentState[];
|
|
264
|
+
focus: "input" | "panel";
|
|
265
|
+
/** Selected row: an agent id, or undefined for main. */
|
|
266
|
+
selected: string | undefined;
|
|
267
|
+
/** Agent shown in place of the main view, if any. */
|
|
268
|
+
view: string | undefined;
|
|
269
|
+
now: number;
|
|
270
|
+
frame: number;
|
|
271
|
+
maxRows: number;
|
|
272
|
+
showEmpty: boolean;
|
|
273
|
+
}
|
|
274
|
+
const s = (n: number) => (n === 1 ? "" : "s");
|
|
275
|
+
const cut = (text: string, width: number) =>
|
|
276
|
+
truncateToWidth(text, Math.max(0, width), "…");
|
|
277
|
+
/** Plain-text truncation. pi-tui wraps its ellipsis in SGR resets, which
|
|
278
|
+
* would end a selected row's reverse video early; these inputs carry no ESC. */
|
|
279
|
+
const clip = (plain: string, width: number) =>
|
|
280
|
+
cut(plain, width).replaceAll("\x1b[0m", "");
|
|
281
|
+
|
|
282
|
+
/** Z3: `● main`, an optional `+K more`, then one row per visible agent:
|
|
283
|
+
* `<status> <kind> <activity…> <elapsed> [· ↓in [↑out]]`. */
|
|
284
|
+
export function panelLines(
|
|
285
|
+
f: PanelFrame,
|
|
286
|
+
width: number,
|
|
287
|
+
paint: Paint,
|
|
288
|
+
): string[] {
|
|
289
|
+
if (f.agents.length === 0 && !f.showEmpty) return [];
|
|
290
|
+
const marked = f.focus === "panel";
|
|
291
|
+
const marker = (selected: boolean) => (!marked ? "" : selected ? "▶" : " ");
|
|
292
|
+
const row = (selected: boolean, plain: string, painted: string) => {
|
|
293
|
+
if (!selected) return cut(painted, width);
|
|
294
|
+
const cell = clip(plain, width);
|
|
295
|
+
return paint("selected", cell + " ".repeat(width - visibleWidth(cell)));
|
|
296
|
+
};
|
|
297
|
+
const onMain = f.selected === undefined;
|
|
298
|
+
const lines = [
|
|
299
|
+
row(
|
|
300
|
+
marked && onMain,
|
|
301
|
+
`${marker(onMain)}● main`,
|
|
302
|
+
`${marker(onMain)}${paint(f.view === undefined ? "accent" : "dim", "●")} main`,
|
|
303
|
+
),
|
|
304
|
+
];
|
|
305
|
+
const { rows, hidden } = visibleAgents(f.agents, f.maxRows, [
|
|
306
|
+
f.selected,
|
|
307
|
+
f.view,
|
|
308
|
+
]);
|
|
309
|
+
if (hidden > 0)
|
|
310
|
+
lines.push(
|
|
311
|
+
cut(`${marker(false)}${paint("dim", ` +${hidden} more`)}`, width),
|
|
312
|
+
);
|
|
313
|
+
const kindWidth = Math.min(
|
|
314
|
+
12,
|
|
315
|
+
Math.max(7, ...rows.map((a) => visibleWidth(a.kind))),
|
|
316
|
+
);
|
|
317
|
+
for (const a of rows) {
|
|
318
|
+
const selected = marked && f.selected === a.id;
|
|
319
|
+
const [symbol, role] = statusSymbol(a, f.frame);
|
|
320
|
+
const kind = clip(a.kind, kindWidth);
|
|
321
|
+
const kindCell = kind + " ".repeat(kindWidth - visibleWidth(kind));
|
|
322
|
+
const used = usage(a);
|
|
323
|
+
const right = `${elapsedText(a, f.now).padStart(8)}${used ? ` · ${used}` : ""}`;
|
|
324
|
+
const head = `${marker(selected)}${symbol} ${kindCell} `;
|
|
325
|
+
const room = width - visibleWidth(head) - visibleWidth(right) - 2;
|
|
326
|
+
const failed = a.status === "error" && a.error;
|
|
327
|
+
const activity = room < 4 ? "" : clip(failed || a.activity, room);
|
|
328
|
+
const gap = " ".repeat(room < 4 ? 1 : room - visibleWidth(activity) + 2);
|
|
329
|
+
lines.push(
|
|
330
|
+
row(
|
|
331
|
+
selected,
|
|
332
|
+
head + activity + gap + right,
|
|
333
|
+
`${marker(selected)}${paint(role, symbol)} ${kindCell} ${
|
|
334
|
+
failed ? paint("error", activity) : activity
|
|
335
|
+
}${gap}${paint("dim", right)}`,
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return lines;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Bottom of Z1: launched/waiting block while any agent runs, then one
|
|
343
|
+
* finished line with the outcome counts. */
|
|
344
|
+
export function summaryLines(
|
|
345
|
+
agents: SubagentState[],
|
|
346
|
+
width: number,
|
|
347
|
+
paint: Paint,
|
|
348
|
+
maxLabels: number,
|
|
349
|
+
): string[] {
|
|
350
|
+
if (agents.length === 0) return [];
|
|
351
|
+
const n = agents.length;
|
|
352
|
+
const running = agents.filter((a) => a.status === "running").length;
|
|
353
|
+
if (running === 0) {
|
|
354
|
+
const count = (status: SubagentStatus) =>
|
|
355
|
+
agents.filter((a) => a.status === status).length;
|
|
356
|
+
const cancelled = count("cancelled");
|
|
357
|
+
const outcome = `ok=${count("done")}, failed=${count("error")}${
|
|
358
|
+
cancelled ? `, cancelled=${cancelled}` : ""
|
|
359
|
+
}`;
|
|
360
|
+
return [
|
|
361
|
+
cut(
|
|
362
|
+
`${paint("success", "✔")} ${n} background agent${s(n)} finished (${outcome}) — select a row below and press Enter to review`,
|
|
363
|
+
width,
|
|
364
|
+
),
|
|
365
|
+
];
|
|
366
|
+
}
|
|
367
|
+
// Running agents first, so a new one is never hidden behind finished ones.
|
|
368
|
+
const shown = [
|
|
369
|
+
...agents.filter((a) => a.status === "running"),
|
|
370
|
+
...agents.filter((a) => a.status !== "running"),
|
|
371
|
+
].slice(0, maxLabels);
|
|
372
|
+
const more = n - shown.length;
|
|
373
|
+
const tree = shown.map((a, i) =>
|
|
374
|
+
cut(
|
|
375
|
+
` ${paint("dim", i === shown.length - 1 && !more ? "└" : "├")} ${a.label}`,
|
|
376
|
+
width,
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
if (more) tree.push(cut(` ${paint("dim", "└")} … +${more} more`, width));
|
|
380
|
+
return [
|
|
381
|
+
cut(
|
|
382
|
+
`${paint("accent", "●")} ${n} background agent${s(n)} launched (↓ to manage)`,
|
|
383
|
+
width,
|
|
384
|
+
),
|
|
385
|
+
...tree,
|
|
386
|
+
cut(
|
|
387
|
+
`${paint("dim", "*")} Waiting for ${running} background agent${s(running)} to finish`,
|
|
388
|
+
width,
|
|
389
|
+
),
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export type PanelKey =
|
|
394
|
+
| "down"
|
|
395
|
+
| "up"
|
|
396
|
+
| "next"
|
|
397
|
+
| "previous"
|
|
398
|
+
| "enter"
|
|
399
|
+
| "escape"
|
|
400
|
+
| "interrupt"
|
|
401
|
+
| "pageUp"
|
|
402
|
+
| "pageDown"
|
|
403
|
+
| "halfUp"
|
|
404
|
+
| "halfDown"
|
|
405
|
+
| "home"
|
|
406
|
+
| "end"
|
|
407
|
+
| "other";
|
|
408
|
+
/** `pass` leaves the key to Pi; `open`/`close` show or leave an agent view. */
|
|
409
|
+
export type PanelAction = "pass" | "consume" | "open" | "close";
|
|
410
|
+
export interface Scroll {
|
|
411
|
+
top: number;
|
|
412
|
+
/** Stick to the newest line as the transcript grows. */
|
|
413
|
+
follow: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Focus model. F_input (default) passes every key except ↓ on an idle, empty
|
|
417
|
+
* editor; F_panel moves the Z3 selection; an agent view keeps F_panel and
|
|
418
|
+
* replaces the main view until Esc, Ctrl+C or Enter on main. */
|
|
419
|
+
export class PanelFocus {
|
|
420
|
+
focus: "input" | "panel" = "input";
|
|
421
|
+
selected: string | undefined;
|
|
422
|
+
view: string | undefined;
|
|
423
|
+
/** Rows of the last rendered agent view, for paging. */
|
|
424
|
+
page = 10;
|
|
425
|
+
private readonly scrolls = new Map<string, Scroll>();
|
|
426
|
+
constructor(private readonly store: SubagentStore) {}
|
|
427
|
+
|
|
428
|
+
key(key: PanelKey, editorIdle: boolean): PanelAction {
|
|
429
|
+
if (this.focus === "input" && this.view === undefined) {
|
|
430
|
+
if (key !== "down" || !editorIdle || this.store.size === 0) return "pass";
|
|
431
|
+
this.focus = "panel";
|
|
432
|
+
this.selected = this.store.ordered()[0].id;
|
|
433
|
+
return "consume";
|
|
434
|
+
}
|
|
435
|
+
const rows = [undefined, ...this.store.ordered().map((a) => a.id)];
|
|
436
|
+
const at = Math.max(0, rows.indexOf(this.selected));
|
|
437
|
+
switch (key) {
|
|
438
|
+
case "up":
|
|
439
|
+
case "previous":
|
|
440
|
+
// ↑ past main returns to the input, the inverse of ↓.
|
|
441
|
+
if (at === 0 && this.view === undefined) this.focus = "input";
|
|
442
|
+
else this.selected = rows[Math.max(0, at - 1)];
|
|
443
|
+
return "consume";
|
|
444
|
+
case "down":
|
|
445
|
+
case "next":
|
|
446
|
+
this.selected = rows[Math.min(rows.length - 1, at + 1)];
|
|
447
|
+
return "consume";
|
|
448
|
+
case "enter":
|
|
449
|
+
if (this.selected === undefined) {
|
|
450
|
+
if (this.view !== undefined) return this.leave();
|
|
451
|
+
this.focus = "input";
|
|
452
|
+
return "consume";
|
|
453
|
+
}
|
|
454
|
+
if (this.view === undefined) {
|
|
455
|
+
this.view = this.selected;
|
|
456
|
+
return "open";
|
|
457
|
+
}
|
|
458
|
+
this.view = this.selected;
|
|
459
|
+
return "consume";
|
|
460
|
+
case "escape":
|
|
461
|
+
case "interrupt":
|
|
462
|
+
if (this.view !== undefined) return this.leave();
|
|
463
|
+
this.focus = "input";
|
|
464
|
+
// Ctrl+C keeps Pi's own clear/interrupt/exit meaning.
|
|
465
|
+
return key === "escape" ? "consume" : "pass";
|
|
466
|
+
case "pageUp":
|
|
467
|
+
case "pageDown":
|
|
468
|
+
case "halfUp":
|
|
469
|
+
case "halfDown":
|
|
470
|
+
case "home":
|
|
471
|
+
case "end":
|
|
472
|
+
if (this.view !== undefined) {
|
|
473
|
+
this.scroll(this.view, key);
|
|
474
|
+
return "consume";
|
|
475
|
+
}
|
|
476
|
+
// The main transcript scrolls itself; Ctrl+U/D must not edit or exit.
|
|
477
|
+
return key === "halfUp" || key === "halfDown" ? "consume" : "pass";
|
|
478
|
+
default:
|
|
479
|
+
if (this.view !== undefined) return "consume";
|
|
480
|
+
this.focus = "input";
|
|
481
|
+
return "pass";
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Pi replaced the agent view (another dialog took the editor slot). */
|
|
485
|
+
abandon(): void {
|
|
486
|
+
this.view = undefined;
|
|
487
|
+
this.focus = "input";
|
|
488
|
+
}
|
|
489
|
+
/** Top line for this view, remembered per agent across switches. */
|
|
490
|
+
settle(id: string, total: number, height: number): number {
|
|
491
|
+
const s = this.scrolls.get(id) ?? { top: 0, follow: true };
|
|
492
|
+
const max = Math.max(0, total - height);
|
|
493
|
+
const top = s.follow ? max : Math.min(Math.max(0, s.top), max);
|
|
494
|
+
this.scrolls.set(id, { top, follow: top >= max });
|
|
495
|
+
this.page = Math.max(1, height);
|
|
496
|
+
return top;
|
|
497
|
+
}
|
|
498
|
+
scrollOf(id: string): Scroll | undefined {
|
|
499
|
+
return this.scrolls.get(id);
|
|
500
|
+
}
|
|
501
|
+
private leave(): PanelAction {
|
|
502
|
+
this.view = undefined;
|
|
503
|
+
return "close";
|
|
504
|
+
}
|
|
505
|
+
private scroll(id: string, key: PanelKey): void {
|
|
506
|
+
const s = this.scrolls.get(id) ?? { top: 0, follow: true };
|
|
507
|
+
const step =
|
|
508
|
+
key === "pageUp" || key === "pageDown"
|
|
509
|
+
? Math.max(1, this.page - 1)
|
|
510
|
+
: Math.max(1, Math.floor(this.page / 2));
|
|
511
|
+
const sign = key === "pageUp" || key === "halfUp" ? -1 : 1;
|
|
512
|
+
this.scrolls.set(
|
|
513
|
+
id,
|
|
514
|
+
key === "home"
|
|
515
|
+
? { top: 0, follow: false }
|
|
516
|
+
: key === "end"
|
|
517
|
+
? { top: s.top, follow: true }
|
|
518
|
+
: { top: s.top + sign * step, follow: false },
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|