@fradser/pi-agent-teams 0.5.3 → 0.7.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/.memory/MEMORY.md +3 -1
- package/.memory/node-terminal-followups.md +3 -2
- package/.memory/team-centric-design.md +21 -0
- package/README.md +103 -63
- package/package.json +4 -4
- package/references/agent-roles.md +77 -0
- package/src/activity.ts +86 -0
- package/src/agents.ts +93 -24
- package/src/follow-up-queue.ts +94 -26
- package/src/guidance.ts +96 -61
- package/src/index.ts +114 -28
- package/src/spawner.ts +194 -350
- package/src/state.ts +252 -565
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +968 -0
- package/src/tools.ts +160 -175
- package/src/types.ts +153 -189
- package/src/ui.ts +459 -226
- package/src/worker.ts +174 -34
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/agents/observer.md +0 -10
- package/agents/reviewer.md +0 -11
- package/agents/specialist.md +0 -10
- package/agents/worker.md +0 -11
- package/src/run-machine.ts +0 -606
- package/src/terminal.ts +0 -34
package/src/ui.ts
CHANGED
|
@@ -1,69 +1,50 @@
|
|
|
1
|
-
/** Passive
|
|
1
|
+
/** Passive team widget and the /agent-teams management console
|
|
2
|
+
* (session teammates + persistent agent roles + board). */
|
|
2
3
|
|
|
3
4
|
import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
4
5
|
import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
|
|
5
6
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import {
|
|
8
8
|
clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
|
|
9
|
-
} from "./console-viewport";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
9
|
+
} from "./console-viewport.ts";
|
|
10
|
+
import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
|
|
11
|
+
import { discoverAgents, resolveAgent, type AgentDefinition } from "./agents.ts";
|
|
12
|
+
import { getState, getTeammate, listTasks, listTeammates, livingTeammates } from "./state.ts";
|
|
13
|
+
import {
|
|
14
|
+
ensureLivePoll,
|
|
15
|
+
formatSilenceDuration,
|
|
16
|
+
runtimeDirPath,
|
|
17
|
+
shutdownTeammate,
|
|
18
|
+
STALL_NOTICE_MS,
|
|
19
|
+
stallSilenceMs,
|
|
20
|
+
} from "./team-machine.ts";
|
|
21
|
+
import type { Teammate } from "./types.ts";
|
|
22
|
+
import { inboxPath } from "./statefile.ts";
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
24
25
|
|
|
25
26
|
const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
|
|
26
27
|
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
27
28
|
let spinnerFrame = 0;
|
|
28
29
|
|
|
29
|
-
/** Stable per-node color (independent of row order). */
|
|
30
30
|
function hashName(name: string): number {
|
|
31
31
|
let h = 0;
|
|
32
|
-
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)
|
|
32
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(0) + i * 7) | 0;
|
|
33
33
|
return Math.abs(h);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Console/widget rows contain node status and leader-report access. */
|
|
41
|
-
function buildPanelRows(): PanelRow[] {
|
|
42
|
-
return listNodes().map((node) => ({ key: node.workerKey }));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function runningTeammateLabel(node: Node): string {
|
|
46
|
-
const frame = PI_SPINNER_FRAMES[spinnerFrame];
|
|
47
|
-
const tool = node.spawn?.activeTool;
|
|
48
|
-
const thinking = (node.spawn?.liveThinking ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
49
|
-
const live = (node.spawn?.liveText ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
50
|
-
// Like the memory package's activity line: show what the worker is doing
|
|
51
|
-
// right now — current tool when one is executing, else its live reasoning,
|
|
52
|
-
// else the latest assistant text.
|
|
53
|
-
const activity = tool
|
|
54
|
-
?? (thinking ? truncateToWidth(thinking, 48) : undefined)
|
|
55
|
-
?? (live ? truncateToWidth(live, 48) : "Working...");
|
|
56
|
-
return `${frame} ${activity}`;
|
|
36
|
+
function colorFor(name: string): (typeof TEAM_COLORS)[number] {
|
|
37
|
+
return TEAM_COLORS[hashName(name) % TEAM_COLORS.length];
|
|
57
38
|
}
|
|
58
39
|
|
|
59
40
|
function ensureSpinner(): void {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
41
|
+
const working = livingTeammates().some(isWorking);
|
|
42
|
+
if (working && !spinnerTimer) {
|
|
62
43
|
spinnerTimer = setInterval(() => {
|
|
63
44
|
spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
|
|
64
45
|
}, PI_SPINNER_INTERVAL_MS);
|
|
65
46
|
spinnerTimer.unref?.();
|
|
66
|
-
} else if (!
|
|
47
|
+
} else if (!working && spinnerTimer) {
|
|
67
48
|
clearInterval(spinnerTimer);
|
|
68
49
|
spinnerTimer = undefined;
|
|
69
50
|
}
|
|
@@ -80,8 +61,7 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
|
|
|
80
61
|
if (!ctx?.ui?.setWidget) return;
|
|
81
62
|
if (ctx.mode && ctx.mode !== "tui") return;
|
|
82
63
|
|
|
83
|
-
|
|
84
|
-
if (running.length === 0) {
|
|
64
|
+
if (livingTeammates().filter(isWorking).length === 0) {
|
|
85
65
|
ctx.ui.setWidget("teammate", undefined);
|
|
86
66
|
return;
|
|
87
67
|
}
|
|
@@ -90,37 +70,22 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
|
|
|
90
70
|
const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
|
|
91
71
|
timer.unref?.();
|
|
92
72
|
const style = createPiThemeStyle(theme);
|
|
93
|
-
const assignedColors = new Map<string, (typeof TEAM_COLORS)[number]>();
|
|
94
73
|
return {
|
|
95
74
|
placement: "belowEditor",
|
|
96
75
|
render: (width: number) => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
if (!runningKeys.has(key)) assignedColors.delete(key);
|
|
102
|
-
}
|
|
103
|
-
const usedColors = new Set(assignedColors.values());
|
|
104
|
-
for (const node of running) {
|
|
105
|
-
if (!assignedColors.has(node.workerKey)) {
|
|
106
|
-
const start = hashName(node.workerKey) % TEAM_COLORS.length;
|
|
107
|
-
const color = TEAM_COLORS.find((_, offset) => !usedColors.has(TEAM_COLORS[(start + offset) % TEAM_COLORS.length]));
|
|
108
|
-
assignedColors.set(node.workerKey, color ?? TEAM_COLORS[start]);
|
|
109
|
-
usedColors.add(color ?? TEAM_COLORS[start]);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
76
|
+
// Only WORKING teammates appear above the input box; idle and
|
|
77
|
+
// stopped teammates stay in the /agent-teams console instead.
|
|
78
|
+
const working = livingTeammates().filter(isWorking);
|
|
79
|
+
if (working.length === 0) return [];
|
|
112
80
|
const lines: string[] = [];
|
|
113
|
-
for (const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const activity = theme.bold(style.fg("accent", activityText));
|
|
122
|
-
const line = ` ${spinner} ${name} ${role} · ${activity}`;
|
|
123
|
-
lines.push(truncateToWidth(line, Math.max(10, width - 1)));
|
|
81
|
+
for (const teammate of working) {
|
|
82
|
+
lines.push(fitTeammateRow(
|
|
83
|
+
PI_SPINNER_FRAMES[spinnerFrame],
|
|
84
|
+
style.fg(colorFor(teammate.name), teammate.name),
|
|
85
|
+
runningTeammateActivity(teammate) + stallSuffix(teammate),
|
|
86
|
+
width,
|
|
87
|
+
(activity) => theme.bold(style.fg("accent", activity)),
|
|
88
|
+
));
|
|
124
89
|
}
|
|
125
90
|
return lines;
|
|
126
91
|
},
|
|
@@ -130,104 +95,241 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
|
|
|
130
95
|
});
|
|
131
96
|
}
|
|
132
97
|
|
|
98
|
+
function isWorking(teammate: { status: string }): boolean {
|
|
99
|
+
return teammate.status === "working" || teammate.status === "starting";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Silence marker appended once a working teammate passes the stall notice threshold. */
|
|
103
|
+
function stallSuffix(teammate: Teammate): string {
|
|
104
|
+
if (!isWorking(teammate)) return "";
|
|
105
|
+
const silence = stallSilenceMs(teammate);
|
|
106
|
+
return silence !== undefined && STALL_NOTICE_MS > 0 && silence >= STALL_NOTICE_MS
|
|
107
|
+
? ` · stalled for ${formatSilenceDuration(silence)}`
|
|
108
|
+
: "";
|
|
109
|
+
}
|
|
110
|
+
|
|
133
111
|
export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
134
112
|
ensureTeamWidget(ctx);
|
|
135
113
|
ensureLivePoll();
|
|
136
114
|
ensureSpinner();
|
|
137
115
|
}
|
|
138
116
|
|
|
117
|
+
function cap(text: string | undefined, maxBytes = 2000): string {
|
|
118
|
+
if (!text) return "";
|
|
119
|
+
return text.length <= maxBytes ? text : `${text.slice(0, maxBytes)}\n…[truncated ${text.length - maxBytes} chars]`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Detail builders ───────────────────────────────────────────────
|
|
139
123
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
...(spawn.liveThinking?.trim() ? spawn.liveThinking.split("\n").map((l) => ` ${l}`) : []),
|
|
167
|
-
...(spawn.liveText?.trim() ? spawn.liveText.split("\n").map((l) => ` ${l}`) : []),
|
|
168
|
-
];
|
|
169
|
-
lines.push(...(activity.length > 0 ? activity : [" Waiting for the worker's first response…"]));
|
|
124
|
+
interface PeerMailLine {
|
|
125
|
+
direction: "sent" | "received";
|
|
126
|
+
counterpart: string;
|
|
127
|
+
subject: string;
|
|
128
|
+
body: string;
|
|
129
|
+
timestamp: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readPeerMail(teammateName: string): PeerMailLine[] {
|
|
133
|
+
const runtimeDir = runtimeDirPath();
|
|
134
|
+
if (!runtimeDir) return [];
|
|
135
|
+
const mailDirPath = path.dirname(inboxPath(path.join(runtimeDir, "state.json"), teammateName));
|
|
136
|
+
let entries: string[] = [];
|
|
137
|
+
try {
|
|
138
|
+
entries = fs.readdirSync(mailDirPath).filter((name) => name.startsWith("inbox-") && name.endsWith(".jsonl"));
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const lines: PeerMailLine[] = [];
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
const recipient = decodeURIComponent(entry.slice("inbox-".length, -".jsonl".length));
|
|
145
|
+
let raw = "";
|
|
146
|
+
try {
|
|
147
|
+
raw = fs.readFileSync(path.join(mailDirPath, entry), "utf-8");
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
170
150
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
151
|
+
for (const line of raw.split("\n")) {
|
|
152
|
+
if (!line.trim()) continue;
|
|
153
|
+
try {
|
|
154
|
+
const message = JSON.parse(line) as { id?: string; from?: string; subject?: string; body?: string; timestamp?: number };
|
|
155
|
+
if (!message.id || !message.from || !message.subject) continue;
|
|
156
|
+
if (message.from !== teammateName && recipient !== teammateName) continue;
|
|
157
|
+
lines.push({
|
|
158
|
+
direction: message.from === teammateName ? "sent" : "received",
|
|
159
|
+
counterpart: message.from === teammateName ? recipient : message.from,
|
|
160
|
+
subject: message.subject,
|
|
161
|
+
body: message.body ?? "",
|
|
162
|
+
timestamp: message.timestamp ?? 0,
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
174
167
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
168
|
+
}
|
|
169
|
+
return lines.sort((a, b) => a.timestamp - b.timestamp);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function indent(text: string): string[] {
|
|
173
|
+
return text.split("\n").map((line) => ` ${line}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function displaySource(sourcePath: string): string {
|
|
177
|
+
const relative = path.relative(process.cwd(), sourcePath);
|
|
178
|
+
return relative.startsWith("..") ? sourcePath : `./${relative}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildTeammateDetail(name: string): string[] {
|
|
182
|
+
const teammate = getTeammate(name);
|
|
183
|
+
if (!teammate) return ["(teammate removed from the roster)"];
|
|
184
|
+
const reports = getState().leaderMailbox.filter((message) => message.from === name);
|
|
185
|
+
const lines: string[] = [
|
|
186
|
+
`@${teammate.name} (${teammate.agent}) [${teammate.status}]`,
|
|
187
|
+
"",
|
|
188
|
+
"== teammate ==",
|
|
189
|
+
` Status: ${teammate.status}${teammate.currentTaskId ? ` | Task: ${teammate.currentTaskId}` : ""}`,
|
|
190
|
+
` Spawn: ${teammate.pid > 0 ? `pid ${teammate.pid}` : "pid unknown"} | Isolation: ${teammate.isolation}`,
|
|
191
|
+
` Created: ${new Date(teammate.createdAt).toLocaleString()}`,
|
|
192
|
+
];
|
|
193
|
+
if (teammate.stoppedAt) lines.push(` Stopped: ${new Date(teammate.stoppedAt).toLocaleString()}`);
|
|
194
|
+
if (isWorking(teammate) && teammate.lastOutputAt !== undefined) {
|
|
195
|
+
const silence = stallSilenceMs(teammate);
|
|
196
|
+
if (silence !== undefined) {
|
|
197
|
+
lines.push(` Last output: ${new Date(teammate.lastOutputAt).toLocaleString()} (${formatSilenceDuration(silence)} ago${stallSuffix(teammate) ? " — stalled" : ""})`);
|
|
178
198
|
}
|
|
179
199
|
}
|
|
200
|
+
if (teammate.usage) lines.push(` Usage: ${teammate.usage.totalTokens} tokens | $${teammate.usage.cost.toFixed(4)}`);
|
|
201
|
+
if (teammate.error) lines.push(` Error: ${teammate.error}`);
|
|
202
|
+
|
|
203
|
+
lines.push("", `== reports to leader (${reports.length}) ==`);
|
|
204
|
+
if (reports.length === 0) lines.push(" (none)");
|
|
205
|
+
for (const report of reports) {
|
|
206
|
+
lines.push(` -> [${report.subject}] ${new Date(report.timestamp).toLocaleString()}`, ...indent(cap(report.body)), "");
|
|
207
|
+
}
|
|
180
208
|
|
|
181
|
-
|
|
182
|
-
|
|
209
|
+
const peer = readPeerMail(name);
|
|
210
|
+
lines.push(`== peer mail (${peer.length}) ==`);
|
|
211
|
+
if (peer.length === 0) lines.push(" (none)");
|
|
212
|
+
for (const mail of peer) {
|
|
213
|
+
const arrow = mail.direction === "sent" ? `to @${mail.counterpart}` : `from @${mail.counterpart}`;
|
|
214
|
+
lines.push(` ${arrow} [${mail.subject}] ${new Date(mail.timestamp).toLocaleString()}`, ...indent(cap(mail.body)), "");
|
|
215
|
+
}
|
|
183
216
|
return lines;
|
|
184
217
|
}
|
|
185
218
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
if (!
|
|
190
|
-
const
|
|
191
|
-
|
|
219
|
+
function buildTaskDetail(taskId: string): string[] {
|
|
220
|
+
const tasks = listTasks();
|
|
221
|
+
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
222
|
+
if (!task) return ["(task not on the board)"];
|
|
223
|
+
const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
|
|
224
|
+
return [
|
|
225
|
+
`[${task.id}] ${task.subject}`,
|
|
226
|
+
"",
|
|
227
|
+
"== task ==",
|
|
228
|
+
` Status: ${task.status}${task.claimedBy ? ` (@${task.claimedBy})` : ""}`,
|
|
229
|
+
...(task.description ? [` Description:`, ...indent(cap(task.description))] : []),
|
|
230
|
+
...(task.dependsOn.length > 0
|
|
231
|
+
? [` Depends on: ${task.dependsOn.join(", ")} (${task.dependsOn.every((dep) => byId.get(dep)?.status === "completed") ? "met" : "unmet"})`]
|
|
232
|
+
: []),
|
|
233
|
+
` Verify: ${task.verify ?? "(none)"}`,
|
|
234
|
+
...(task.result ? [" Result:", ...indent(cap(task.result))] : []),
|
|
235
|
+
...(task.errorMessage ? [" Error:", ...indent(cap(task.errorMessage))] : []),
|
|
236
|
+
` Created: ${new Date(task.createdAt).toLocaleString()}`,
|
|
237
|
+
...(task.completedAt ? [` Completed: ${new Date(task.completedAt).toLocaleString()}`] : []),
|
|
238
|
+
];
|
|
239
|
+
}
|
|
192
240
|
|
|
193
|
-
|
|
194
|
-
|
|
241
|
+
function buildRoleDetail(name: string): string[] {
|
|
242
|
+
const def = resolveAgent(name);
|
|
243
|
+
if (!def) return ["(agent definition not found)"];
|
|
244
|
+
return [
|
|
245
|
+
`@${def.name} (${def.scope})${def.gitManaged ? " [git-managed]" : " [local]"}`,
|
|
195
246
|
"",
|
|
196
|
-
"==
|
|
197
|
-
|
|
247
|
+
"== role ==",
|
|
248
|
+
` Source: ${displaySource(def.source)}`,
|
|
249
|
+
` Description: ${def.description || "(none)"}`,
|
|
250
|
+
` Tools: ${def.tools.join(", ") || "(role defaults)"}`,
|
|
251
|
+
` Model: ${def.model ?? "(session default)"}`,
|
|
252
|
+
` Verify: ${def.verify ?? "(none)"}`,
|
|
253
|
+
` Worktree: ${def.worktree ? "true" : "false"}`,
|
|
254
|
+
` Living instances: ${livingTeammates().filter((teammate) => teammate.agent === def.name).length}`,
|
|
198
255
|
"",
|
|
199
|
-
|
|
200
|
-
...(
|
|
201
|
-
? ["(none)"]
|
|
202
|
-
: outgoing.flatMap((m) => {
|
|
203
|
-
const time = new Date(m.timestamp).toLocaleString();
|
|
204
|
-
return [
|
|
205
|
-
`→ [${m.id}] ${m.subject} | ${time}`,
|
|
206
|
-
m.runId ? ` run ${m.runId}` : "",
|
|
207
|
-
m.body,
|
|
208
|
-
"",
|
|
209
|
-
];
|
|
210
|
-
})),
|
|
256
|
+
"== role prompt ==",
|
|
257
|
+
...indent(cap(def.prompt)),
|
|
211
258
|
];
|
|
212
|
-
return lines;
|
|
213
259
|
}
|
|
214
260
|
|
|
215
|
-
|
|
216
|
-
|
|
261
|
+
// ── Full-screen console ───────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
type ConsolePage = "roster" | "board";
|
|
264
|
+
|
|
265
|
+
interface RosterRow {
|
|
266
|
+
key: string;
|
|
267
|
+
kind: "teammate";
|
|
268
|
+
}
|
|
269
|
+
interface RoleRow {
|
|
270
|
+
key: string;
|
|
271
|
+
kind: "role";
|
|
272
|
+
}
|
|
273
|
+
interface BoardRow {
|
|
274
|
+
key: string;
|
|
275
|
+
kind: "task";
|
|
276
|
+
}
|
|
277
|
+
type ConsoleRow = RosterRow | RoleRow | BoardRow;
|
|
278
|
+
|
|
279
|
+
/** SGR mouse-wheel events carry a button code; return signed line delta. */
|
|
280
|
+
function wheelDelta(data: string): number | undefined {
|
|
281
|
+
const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
|
|
282
|
+
if (!match) return undefined;
|
|
283
|
+
const button = Number.parseInt(match[1], 10);
|
|
284
|
+
if ((button & 64) === 0) return undefined;
|
|
285
|
+
const direction = button & 3;
|
|
286
|
+
if (direction === 0) return -1;
|
|
287
|
+
if (direction === 1) return 1;
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Chrome lines around the list viewport: border, header, spacer, footer, border. */
|
|
292
|
+
const LIST_CHROME_LINES = 5;
|
|
293
|
+
|
|
294
|
+
/** Full-screen Team Console — owns input via ctx.ui.custom. Pages: roster /
|
|
295
|
+
* board; each row opens a scrolling detail view. */
|
|
217
296
|
export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void> {
|
|
218
297
|
return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
298
|
+
let page: ConsolePage = "roster";
|
|
219
299
|
let mode: "list" | "detail" = "list";
|
|
220
|
-
let
|
|
300
|
+
let selectedRoster = 0;
|
|
301
|
+
let selectedBoard = 0;
|
|
302
|
+
let listOffset = 0;
|
|
303
|
+
let detailKind: ConsoleRow["kind"] = "teammate";
|
|
221
304
|
let detailKey = "";
|
|
222
305
|
let offset = 0;
|
|
306
|
+
let confirmName: string | undefined;
|
|
223
307
|
let closed = false;
|
|
224
308
|
let renderTimer: ReturnType<typeof setInterval> | undefined;
|
|
309
|
+
|
|
310
|
+
// Definitions are reread lazily so mid-session role creation shows up,
|
|
311
|
+
// but never once per animation frame.
|
|
312
|
+
let rolesCache: AgentDefinition[] = [];
|
|
313
|
+
let rolesCacheAt = 0;
|
|
314
|
+
const ROLES_CACHE_TTL_MS = 2000;
|
|
315
|
+
const getRoles = (): AgentDefinition[] => {
|
|
316
|
+
const now = Date.now();
|
|
317
|
+
if (now - rolesCacheAt > ROLES_CACHE_TTL_MS) {
|
|
318
|
+
rolesCache = [...discoverAgents().values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
319
|
+
rolesCacheAt = now;
|
|
320
|
+
}
|
|
321
|
+
return rolesCache;
|
|
322
|
+
};
|
|
323
|
+
|
|
225
324
|
const requestRender = () => {
|
|
226
325
|
if (!closed) tui.requestRender();
|
|
227
326
|
};
|
|
228
327
|
const startLiveRefresh = () => {
|
|
229
328
|
if (renderTimer) return;
|
|
230
|
-
renderTimer = setInterval(
|
|
329
|
+
renderTimer = setInterval(() => {
|
|
330
|
+
// Redraw only while something can animate (spinner/activity/stall).
|
|
331
|
+
if (livingTeammates().some(isWorking)) requestRender();
|
|
332
|
+
}, PI_SPINNER_INTERVAL_MS);
|
|
231
333
|
renderTimer.unref?.();
|
|
232
334
|
};
|
|
233
335
|
const stopLiveRefresh = () => {
|
|
@@ -237,9 +339,18 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
237
339
|
};
|
|
238
340
|
startLiveRefresh();
|
|
239
341
|
|
|
240
|
-
// btw-style callbacks (same accent/muted/dim/border/success/error language as pi-btw-fradser).
|
|
241
342
|
const style = createPiThemeStyle(theme);
|
|
242
343
|
|
|
344
|
+
const currentRows = (): ConsoleRow[] =>
|
|
345
|
+
page === "roster"
|
|
346
|
+
// The console is the management surface: idle and stopped stay
|
|
347
|
+
// visible, and persistent agent roles are listed after the session.
|
|
348
|
+
? [
|
|
349
|
+
...listTeammates().map((t) => ({ key: t.name, kind: "teammate" as const })),
|
|
350
|
+
...getRoles().map((def) => ({ key: def.name, kind: "role" as const })),
|
|
351
|
+
]
|
|
352
|
+
: listTasks().map((task) => ({ key: task.id, kind: "task" as const }));
|
|
353
|
+
|
|
243
354
|
const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
|
|
244
355
|
const wrapped = wrapConsoleDetail(full, width);
|
|
245
356
|
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
@@ -250,44 +361,130 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
250
361
|
};
|
|
251
362
|
};
|
|
252
363
|
|
|
364
|
+
const headerLine = (): string => {
|
|
365
|
+
const alive = livingTeammates();
|
|
366
|
+
const tasks = listTasks();
|
|
367
|
+
return `team ${alive.length} alive · ${alive.filter(isWorking).length} working · ${getRoles().length} roles · board ${tasks.filter((task) => task.status === "pending").length}p/${tasks.filter((task) => task.status === "claimed").length}c/${tasks.filter((task) => task.status === "completed").length}d · ${page}`;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
interface ContentLine {
|
|
371
|
+
text: string;
|
|
372
|
+
/** Selectable row index, or -1 for section chrome. */
|
|
373
|
+
select: number;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const teammateRowText = (key: string, selected: boolean): string => {
|
|
377
|
+
const teammate = getTeammate(key)!;
|
|
378
|
+
const marker = selected ? style.accent("❯ ") : " ";
|
|
379
|
+
const name = theme.bold(theme.fg(colorFor(teammate.name), `@${teammate.name}`));
|
|
380
|
+
if (isWorking(teammate)) {
|
|
381
|
+
const prefix = `${marker}${name} `;
|
|
382
|
+
const width = tui.terminal.columns;
|
|
383
|
+
const available = Math.max(0, width - prefix.length - PI_SPINNER_FRAMES[spinnerFrame].length - 2);
|
|
384
|
+
const status = theme.fg("warning", formatTeammateLabel(PI_SPINNER_FRAMES[spinnerFrame], runningTeammateActivity(teammate) + stallSuffix(teammate), available));
|
|
385
|
+
return `${prefix}${status}`;
|
|
386
|
+
}
|
|
387
|
+
if (teammate.status === "idle") {
|
|
388
|
+
return `${marker}${name} ${style.dim(`○ idle${teammate.currentTaskId ? ` · ${teammate.currentTaskId}` : ""}`)}`;
|
|
389
|
+
}
|
|
390
|
+
return `${marker}${name} ${theme.fg("warning", "■ stopped")}`;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const roleRowText = (key: string, selected: boolean): string => {
|
|
394
|
+
const def = getRoles().find((candidate) => candidate.name === key)!;
|
|
395
|
+
const marker = selected ? style.accent("❯ ") : " ";
|
|
396
|
+
const name = theme.bold(theme.fg(colorFor(key), `@${key}`));
|
|
397
|
+
const live = livingTeammates().filter((teammate) => teammate.agent === key && teammate.status !== "stopped").length;
|
|
398
|
+
const liveTag = live > 0 ? style.success(`[${live} live]`) : style.dim("[0 live]");
|
|
399
|
+
return `${marker}${name} ${style.dim(def.scope)} ${style.dim(displaySource(def.source))} ${liveTag}`;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const taskRowText = (key: string, selected: boolean): string => {
|
|
403
|
+
const task = listTasks().find((candidate) => candidate.id === key)!;
|
|
404
|
+
const marker = selected ? style.accent("❯ ") : " ";
|
|
405
|
+
const label = theme.fg(colorFor(key), `[${key}]`);
|
|
406
|
+
const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
|
|
407
|
+
const statusText = task.status === "completed"
|
|
408
|
+
? style.success("✓")
|
|
409
|
+
: task.status === "claimed"
|
|
410
|
+
? theme.fg("warning", "◐ claimed")
|
|
411
|
+
: style.dim("○ pending");
|
|
412
|
+
return `${marker}${label} ${statusText} ${theme.fg("customMessageText", task.subject)}${holder}`;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const buildContent = (): ContentLine[] => {
|
|
416
|
+
const rows = currentRows();
|
|
417
|
+
const lines: ContentLine[] = [];
|
|
418
|
+
if (page === "board") {
|
|
419
|
+
if (rows.length === 0) {
|
|
420
|
+
lines.push({ text: style.dim("The task board is empty. Create tasks with task_create."), select: -1 });
|
|
421
|
+
}
|
|
422
|
+
rows.forEach((row, index) => lines.push({ text: taskRowText(row.key, index === currentPageSelection(rows.length)), select: index }));
|
|
423
|
+
return lines;
|
|
424
|
+
}
|
|
425
|
+
const teammates = rows.filter((row): row is RosterRow => row.kind === "teammate");
|
|
426
|
+
const roles = rows.filter((row): row is RoleRow => row.kind === "role");
|
|
427
|
+
const selection = currentPageSelection(rows.length);
|
|
428
|
+
lines.push({ text: style.dim("== teammates (this session) =="), select: -1 });
|
|
429
|
+
if (teammates.length === 0) {
|
|
430
|
+
lines.push({ text: style.dim("No teammates this session. Ask the model to spawn one."), select: -1 });
|
|
431
|
+
}
|
|
432
|
+
teammates.forEach((row, index) => lines.push({ text: teammateRowText(row.key, index === selection), select: index }));
|
|
433
|
+
lines.push({ text: "", select: -1 });
|
|
434
|
+
lines.push({ text: style.dim("== agent roles (persistent definitions) =="), select: -1 });
|
|
435
|
+
if (roles.length === 0) {
|
|
436
|
+
lines.push({ text: style.dim("No agent definitions discovered."), select: -1 });
|
|
437
|
+
}
|
|
438
|
+
roles.forEach((row, index) => lines.push({ text: roleRowText(row.key, teammates.length + index === selection), select: teammates.length + index }));
|
|
439
|
+
return lines;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const listViewport = (): number => Math.max(1, tui.terminal.rows - LIST_CHROME_LINES);
|
|
443
|
+
|
|
253
444
|
const renderList = (width: number): string[] => {
|
|
254
|
-
const rows = buildPanelRows();
|
|
255
|
-
if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
|
|
256
445
|
const border = style.border("─".repeat(Math.max(1, width)));
|
|
257
|
-
const
|
|
446
|
+
const content = buildContent();
|
|
447
|
+
const viewport = listViewport();
|
|
448
|
+
const selection = currentPageSelection(currentRows().length);
|
|
449
|
+
const selectedIndex = content.findIndex((line) => line.select === selection);
|
|
450
|
+
if (selectedIndex >= 0) {
|
|
451
|
+
if (selectedIndex < listOffset) listOffset = selectedIndex;
|
|
452
|
+
else if (selectedIndex >= listOffset + viewport) listOffset = selectedIndex - viewport + 1;
|
|
453
|
+
}
|
|
454
|
+
listOffset = clampConsoleScroll(listOffset, content.length, viewport);
|
|
455
|
+
const footer = confirmName
|
|
456
|
+
? theme.fg("warning", `Shut down @${confirmName}? Its claimed task returns to the board · y yes / n no`)
|
|
457
|
+
: style.dim(page === "roster"
|
|
458
|
+
? "↑↓ select · enter open · tab board · x shutdown · esc/q close"
|
|
459
|
+
: "↑↓ select · enter open · tab roster · esc/q close");
|
|
460
|
+
return [
|
|
258
461
|
border,
|
|
259
|
-
style.accent(truncateToWidth(
|
|
462
|
+
style.accent(truncateToWidth(headerLine(), width)),
|
|
463
|
+
"",
|
|
464
|
+
...content.slice(listOffset, listOffset + viewport)
|
|
465
|
+
.map((line) => truncateToWidth(line.text, Math.max(10, width - 1))),
|
|
260
466
|
"",
|
|
467
|
+
truncateToWidth(footer, Math.max(10, width - 1)),
|
|
468
|
+
border,
|
|
261
469
|
];
|
|
262
|
-
for (let i = 0; i < rows.length; i++) {
|
|
263
|
-
const row = rows[i];
|
|
264
|
-
const marker = i === selected ? style.accent("❯ ") : " ";
|
|
265
|
-
const entry = getNodeByWorkerKey(row.key);
|
|
266
|
-
if (!entry) continue;
|
|
267
|
-
const { node } = entry;
|
|
268
|
-
const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
|
|
269
|
-
const name = theme.bold(theme.fg(color, node.id));
|
|
270
|
-
const role = style.muted(`(${node.agent})`);
|
|
271
|
-
const status = node.status === "running"
|
|
272
|
-
? theme.fg("warning", runningTeammateLabel(node))
|
|
273
|
-
: node.status === "completed"
|
|
274
|
-
? style.success("✓ completed")
|
|
275
|
-
: node.status === "failed"
|
|
276
|
-
? style.error("✗ failed")
|
|
277
|
-
: style.dim(`○ ${node.status}`);
|
|
278
|
-
lines.push(`${marker}${name} ${role} ${status}`);
|
|
279
|
-
}
|
|
280
|
-
lines.push("", style.dim("↑↓ select · enter open · esc/q close · x cancel"), border);
|
|
281
|
-
return lines.map((l) => truncateToWidth(l, Math.max(10, width - 1)));
|
|
282
470
|
};
|
|
283
471
|
|
|
472
|
+
const currentPageSelection = (rowCount: number): number => {
|
|
473
|
+
const selection = page === "roster" ? selectedRoster : selectedBoard;
|
|
474
|
+
return rowCount === 0 ? 0 : Math.min(selection, rowCount - 1);
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const detailTitle = (): string =>
|
|
478
|
+
detailKind === "task" ? `[${detailKey}]` : detailKind === "role" ? `@${detailKey} · role` : `@${detailKey}`;
|
|
479
|
+
|
|
284
480
|
const renderDetail = (width: number): string[] => {
|
|
285
481
|
const border = style.border("─".repeat(Math.max(1, width)));
|
|
286
|
-
const
|
|
482
|
+
const source = detailSource();
|
|
483
|
+
const detail = windowLines(source, width);
|
|
287
484
|
const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
|
|
288
485
|
const lines = [
|
|
289
486
|
border,
|
|
290
|
-
style.accent(truncateToWidth(`agent-teams ${
|
|
487
|
+
style.accent(truncateToWidth(`agent-teams ${detailTitle()}`, width)),
|
|
291
488
|
"",
|
|
292
489
|
...detail.lines.map((line) => ` ${line}`),
|
|
293
490
|
"",
|
|
@@ -297,90 +494,126 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
|
|
|
297
494
|
return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
|
|
298
495
|
};
|
|
299
496
|
|
|
497
|
+
const detailSource = (): string[] => {
|
|
498
|
+
if (detailKind === "task") return buildTaskDetail(detailKey);
|
|
499
|
+
if (detailKind === "role") return buildRoleDetail(detailKey);
|
|
500
|
+
return buildTeammateDetail(detailKey);
|
|
501
|
+
};
|
|
502
|
+
|
|
300
503
|
return {
|
|
301
|
-
render: (width) =>
|
|
302
|
-
mode === "list" ? renderList(width) : renderDetail(width),
|
|
504
|
+
render: (width) => (mode === "list" ? renderList(width) : renderDetail(width)),
|
|
303
505
|
handleInput: (data: string) => {
|
|
304
|
-
// Detail mode: Esc returns to the list, q closes.
|
|
305
506
|
if (mode !== "list") {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
319
|
-
// Mouse-wheel scrolling (SGR wheel events, same as pi-btw-fradser).
|
|
320
|
-
const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
|
|
321
|
-
if (sgrWheel) {
|
|
322
|
-
const button = Number.parseInt(sgrWheel[1], 10);
|
|
323
|
-
if ((button & 64) !== 0) {
|
|
324
|
-
const direction = button & 3;
|
|
325
|
-
if (direction === 0) offset = scrollConsoleDetail(offset, -3, detail.length, viewport);
|
|
326
|
-
else if (direction === 1) offset = scrollConsoleDetail(offset, 3, detail.length, viewport);
|
|
327
|
-
}
|
|
328
|
-
return;
|
|
507
|
+
handleDetailInput(data);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (confirmName) {
|
|
511
|
+
if (data === "y" || data === "Y") {
|
|
512
|
+
const name = confirmName;
|
|
513
|
+
confirmName = undefined;
|
|
514
|
+
void shutdownFromConsole(ctx, name);
|
|
515
|
+
} else if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
|
|
516
|
+
confirmName = undefined;
|
|
517
|
+
} else if (data === "q" || data === "Q") {
|
|
518
|
+
closeConsole(done);
|
|
329
519
|
}
|
|
330
|
-
if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, detail.length, viewport);
|
|
331
|
-
else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, detail.length, viewport);
|
|
332
|
-
else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), detail.length, viewport);
|
|
333
|
-
else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), detail.length, viewport);
|
|
334
|
-
else if (matchesKey(data, Key.home)) offset = 0;
|
|
335
|
-
else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, detail.length, viewport);
|
|
336
520
|
return;
|
|
337
521
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
522
|
+
const rows = currentRows();
|
|
523
|
+
if (matchesKey(data, Key.tab)) {
|
|
524
|
+
page = page === "roster" ? "board" : "roster";
|
|
525
|
+
listOffset = 0;
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (matchesKey(data, Key.down)) bumpSelection(1, rows.length);
|
|
529
|
+
if (matchesKey(data, Key.up)) bumpSelection(-1, rows.length);
|
|
343
530
|
if (matchesKey(data, Key.enter)) {
|
|
344
|
-
const
|
|
345
|
-
|
|
531
|
+
const selection = currentPageSelection(rows.length);
|
|
532
|
+
const row = rows[selection];
|
|
533
|
+
if (row) {
|
|
346
534
|
mode = "detail";
|
|
347
|
-
detailKey = row.key;
|
|
348
535
|
offset = 0;
|
|
536
|
+
detailKind = row.kind;
|
|
537
|
+
detailKey = row.key;
|
|
349
538
|
}
|
|
350
539
|
return;
|
|
351
540
|
}
|
|
352
|
-
if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
|
|
353
|
-
closed = true;
|
|
354
|
-
stopLiveRefresh();
|
|
355
|
-
done();
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
541
|
if (data === "x" || data === "X") {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (entry && (entry.node.status === "running" || entry.node.status === "pending")) {
|
|
364
|
-
const runId = entry.run.id;
|
|
365
|
-
const nodeId = entry.node.id;
|
|
366
|
-
void cancelNodeAndTerminate(runId, nodeId, { ui: ctx.ui })
|
|
367
|
-
.then((result) => {
|
|
368
|
-
if (result.ok) ctx.ui.notify(`Cancelled node [${runId}/${nodeId}] — the rest of the run continues.`, "info");
|
|
369
|
-
else ctx.ui.notify(result.error, "error");
|
|
370
|
-
})
|
|
371
|
-
.catch(() => false);
|
|
542
|
+
const selection = currentPageSelection(rows.length);
|
|
543
|
+
const row = rows[selection];
|
|
544
|
+
if (page === "roster" && row?.kind === "teammate" && getTeammate(row.key)?.status !== "stopped") {
|
|
545
|
+
confirmName = row.key;
|
|
372
546
|
}
|
|
373
547
|
return;
|
|
374
548
|
}
|
|
549
|
+
const wheel = wheelDelta(data);
|
|
550
|
+
if (wheel !== undefined) {
|
|
551
|
+
scrollList(wheel * 3);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (matchesKey(data, Key.pageUp)) scrollList(-listViewport());
|
|
555
|
+
else if (matchesKey(data, Key.pageDown)) scrollList(listViewport());
|
|
556
|
+
else if (matchesKey(data, Key.home)) listOffset = 0;
|
|
557
|
+
else if (matchesKey(data, Key.end)) listOffset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, buildContent().length, listViewport());
|
|
558
|
+
else if (matchesKey(data, Key.escape) || data === "q" || data === "Q") closeConsole(done);
|
|
375
559
|
},
|
|
376
|
-
invalidate: () =>
|
|
377
|
-
requestRender();
|
|
378
|
-
},
|
|
560
|
+
invalidate: () => requestRender(),
|
|
379
561
|
dispose: () => {
|
|
380
562
|
closed = true;
|
|
381
563
|
stopLiveRefresh();
|
|
382
564
|
},
|
|
383
565
|
};
|
|
566
|
+
|
|
567
|
+
function closeConsole(doneFn: () => void): void {
|
|
568
|
+
closed = true;
|
|
569
|
+
stopLiveRefresh();
|
|
570
|
+
doneFn();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function bumpSelection(delta: number, rowCount: number): void {
|
|
574
|
+
if (rowCount === 0) return;
|
|
575
|
+
if (page === "roster") selectedRoster = clampIndex(selectedRoster + delta, rowCount);
|
|
576
|
+
else selectedBoard = clampIndex(selectedBoard + delta, rowCount);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function scrollList(delta: number): void {
|
|
580
|
+
listOffset = clampConsoleScroll(listOffset + delta, buildContent().length, listViewport());
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function handleDetailInput(data: string): void {
|
|
584
|
+
const source = detailSource();
|
|
585
|
+
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
586
|
+
if (matchesKey(data, Key.escape)) {
|
|
587
|
+
mode = "list";
|
|
588
|
+
offset = 0;
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (data === "q" || data === "Q") {
|
|
592
|
+
closeConsole(done);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const total = wrapConsoleDetail(source, tui.terminal.columns).length;
|
|
596
|
+
const wheel = wheelDelta(data);
|
|
597
|
+
if (wheel !== undefined) {
|
|
598
|
+
offset = scrollConsoleDetail(offset, wheel * 3, total, viewport);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, total, viewport);
|
|
602
|
+
else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, total, viewport);
|
|
603
|
+
else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), total, viewport);
|
|
604
|
+
else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), total, viewport);
|
|
605
|
+
else if (matchesKey(data, Key.home)) offset = 0;
|
|
606
|
+
else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, total, viewport);
|
|
607
|
+
}
|
|
384
608
|
});
|
|
385
609
|
}
|
|
386
610
|
|
|
611
|
+
async function shutdownFromConsole(ctx: { ui: ExtensionUIContext }, name: string): Promise<void> {
|
|
612
|
+
const result = await shutdownTeammate(name);
|
|
613
|
+
if (result.ok) ctx.ui.notify(result.body, "info");
|
|
614
|
+
else ctx.ui.notify(result.error, "error");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function clampIndex(index: number, rowCount: number): number {
|
|
618
|
+
return Math.max(0, Math.min(rowCount - 1, index));
|
|
619
|
+
}
|