pi-agent-squad 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/README.md +205 -0
- package/agents/actor.md +51 -0
- package/agents/planner.md +57 -0
- package/agents/reviewer.md +40 -0
- package/agents.ts +87 -0
- package/index.ts +1204 -0
- package/message.ts +572 -0
- package/orchestrator.md +131 -0
- package/package.json +36 -0
- package/pool.ts +578 -0
- package/session-ui.ts +648 -0
- package/session.ts +8 -0
- package/spawn.ts +457 -0
- package/wait-graph.ts +56 -0
package/session-ui.ts
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import { UserMessageComponent, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component, Focusable } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
Input,
|
|
5
|
+
Markdown,
|
|
6
|
+
matchesKey,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import {
|
|
11
|
+
CompactExternalGroupComponent,
|
|
12
|
+
getCompactMarkdownTheme,
|
|
13
|
+
type CompactExternalGroup,
|
|
14
|
+
type CompactExternalTool,
|
|
15
|
+
} from "pi-compact-ui";
|
|
16
|
+
import type { SubagentSessionHandle } from "./session.ts";
|
|
17
|
+
|
|
18
|
+
// In fullscreen TUI mode (TuiAltScreen), the viewport input listener runs before the
|
|
19
|
+
// focused component and consumes these bindings to scroll the underlying transcript,
|
|
20
|
+
// so pageUp/pageDown/home/end never reach a focused overlay's handleInput. While the
|
|
21
|
+
// session overlay is open we temporarily clear them so the overlay can scroll itself,
|
|
22
|
+
// and restore the user's bindings when it closes.
|
|
23
|
+
const ALTSCREEN_SCROLL_BINDINGS = [
|
|
24
|
+
"tui.altScreen.pageUp",
|
|
25
|
+
"tui.altScreen.pageDown",
|
|
26
|
+
"tui.altScreen.halfPageUp",
|
|
27
|
+
"tui.altScreen.halfPageDown",
|
|
28
|
+
"tui.altScreen.top",
|
|
29
|
+
"tui.altScreen.bottom",
|
|
30
|
+
"tui.altScreen.previousPrompt",
|
|
31
|
+
"tui.altScreen.nextPrompt",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
type UserTranscriptItem = {
|
|
35
|
+
kind: "user";
|
|
36
|
+
text: string;
|
|
37
|
+
component: UserMessageComponent;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type AssistantTranscriptItem = {
|
|
41
|
+
kind: "assistant";
|
|
42
|
+
text: string;
|
|
43
|
+
streaming: boolean;
|
|
44
|
+
component: Markdown;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type CompactTranscriptItem = {
|
|
48
|
+
kind: "compact";
|
|
49
|
+
state: CompactExternalGroup;
|
|
50
|
+
component: CompactExternalGroupComponent;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type NoticeTranscriptItem = {
|
|
54
|
+
kind: "notice";
|
|
55
|
+
text: string;
|
|
56
|
+
color: "dim" | "muted" | "error";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type TranscriptItem =
|
|
60
|
+
| UserTranscriptItem
|
|
61
|
+
| AssistantTranscriptItem
|
|
62
|
+
| CompactTranscriptItem
|
|
63
|
+
| NoticeTranscriptItem;
|
|
64
|
+
|
|
65
|
+
function contentText(content: unknown): string {
|
|
66
|
+
if (typeof content === "string") return content;
|
|
67
|
+
if (!Array.isArray(content)) return "";
|
|
68
|
+
return content
|
|
69
|
+
.map((block: any) => (block?.type === "text" ? String(block.text ?? "") : ""))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function thinkingText(content: unknown): string {
|
|
75
|
+
if (!Array.isArray(content)) return "";
|
|
76
|
+
return content
|
|
77
|
+
.map((block: any) =>
|
|
78
|
+
block?.type === "thinking" ? String(block.thinking ?? block.text ?? "") : "",
|
|
79
|
+
)
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function oneLine(value: unknown, max = 160): string {
|
|
85
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
86
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toolCallId(value: any): string {
|
|
90
|
+
return String(value?.toolCallId ?? value?.id ?? "");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toolCallName(value: any): string {
|
|
94
|
+
return String(value?.toolName ?? value?.name ?? "unknown");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toolCallArgs(value: any): any {
|
|
98
|
+
return value?.args ?? value?.arguments ?? {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toolResultText(value: any): string {
|
|
102
|
+
return contentText(value?.content ?? value)
|
|
103
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
104
|
+
.replace(/\r/g, "\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function estimateThinkingTokens(text: string): number {
|
|
108
|
+
return Math.ceil(text.length / 4);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
class SubagentSessionComponent implements Component, Focusable {
|
|
112
|
+
private readonly input = new Input();
|
|
113
|
+
private readonly items: TranscriptItem[] = [];
|
|
114
|
+
private readonly tools = new Map<string, CompactExternalTool>();
|
|
115
|
+
private readonly toolGroups = new Map<string, CompactTranscriptItem>();
|
|
116
|
+
private currentGroup: CompactTranscriptItem | undefined;
|
|
117
|
+
private liveAssistant: AssistantTranscriptItem | undefined;
|
|
118
|
+
private status = "connected";
|
|
119
|
+
private closed = false;
|
|
120
|
+
private _focused = false;
|
|
121
|
+
private expanded = false;
|
|
122
|
+
private scrollFromBottom = 0;
|
|
123
|
+
private disposed = false;
|
|
124
|
+
private readonly animationTimer: ReturnType<typeof setInterval>;
|
|
125
|
+
private savedUserBindings: Record<string, unknown> | undefined;
|
|
126
|
+
|
|
127
|
+
constructor(
|
|
128
|
+
private readonly tui: any,
|
|
129
|
+
private readonly theme: any,
|
|
130
|
+
private readonly session: SubagentSessionHandle,
|
|
131
|
+
messages: any[],
|
|
132
|
+
private readonly done: () => void,
|
|
133
|
+
private readonly unsubscribe: () => void,
|
|
134
|
+
private readonly keybindings?: any,
|
|
135
|
+
) {
|
|
136
|
+
this.restoreHistory(messages);
|
|
137
|
+
this.input.onSubmit = (value) => this.submit(value);
|
|
138
|
+
this.animationTimer = setInterval(() => {
|
|
139
|
+
if (!this.disposed) this.tui.requestRender();
|
|
140
|
+
}, 300);
|
|
141
|
+
this.animationTimer.unref?.();
|
|
142
|
+
this.suspendAltScreenScroll();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* While the overlay is focused, stop the fullscreen viewport from hijacking
|
|
147
|
+
* pageUp/pageDown/home/end so they reach this component's handleInput instead of
|
|
148
|
+
* scrolling the underlying main transcript.
|
|
149
|
+
*/
|
|
150
|
+
private suspendAltScreenScroll(): void {
|
|
151
|
+
const kb = this.keybindings;
|
|
152
|
+
if (!kb || typeof kb.getUserBindings !== "function" || typeof kb.setUserBindings !== "function") {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const saved = kb.getUserBindings();
|
|
157
|
+
const next: Record<string, unknown> = { ...saved };
|
|
158
|
+
for (const id of ALTSCREEN_SCROLL_BINDINGS) next[id] = [];
|
|
159
|
+
kb.setUserBindings(next);
|
|
160
|
+
this.savedUserBindings = saved;
|
|
161
|
+
} catch {
|
|
162
|
+
this.savedUserBindings = undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private restoreAltScreenScroll(): void {
|
|
167
|
+
if (this.savedUserBindings === undefined) return;
|
|
168
|
+
const saved = this.savedUserBindings;
|
|
169
|
+
this.savedUserBindings = undefined;
|
|
170
|
+
try {
|
|
171
|
+
this.keybindings?.setUserBindings?.(saved);
|
|
172
|
+
} catch {
|
|
173
|
+
/* best-effort restore */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get focused(): boolean {
|
|
178
|
+
return this._focused;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
set focused(value: boolean) {
|
|
182
|
+
this._focused = value;
|
|
183
|
+
this.input.focused = value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private pushItem(item: TranscriptItem): void {
|
|
187
|
+
this.items.push(item);
|
|
188
|
+
if (this.items.length > 160) this.items.splice(0, this.items.length - 160);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private addUser(text: string): UserTranscriptItem | undefined {
|
|
192
|
+
const value = text.trim();
|
|
193
|
+
if (!value) return undefined;
|
|
194
|
+
const previous = this.items.at(-1);
|
|
195
|
+
if (previous?.kind === "user" && previous.text === value) return previous;
|
|
196
|
+
const item: UserTranscriptItem = {
|
|
197
|
+
kind: "user",
|
|
198
|
+
text: value,
|
|
199
|
+
component: new UserMessageComponent(value, getMarkdownTheme(), 1),
|
|
200
|
+
};
|
|
201
|
+
this.pushItem(item);
|
|
202
|
+
this.liveAssistant = undefined;
|
|
203
|
+
return item;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private addAssistant(text: string, streaming: boolean): AssistantTranscriptItem | undefined {
|
|
207
|
+
const value = streaming ? text : text.trim();
|
|
208
|
+
if (!value && !streaming) return undefined;
|
|
209
|
+
this.sealCurrentGroup();
|
|
210
|
+
const previous = this.items.at(-1);
|
|
211
|
+
if (!streaming && previous?.kind === "assistant" && previous.text.trim() === value.trim()) {
|
|
212
|
+
return previous;
|
|
213
|
+
}
|
|
214
|
+
const component = new Markdown(value, 1, 0, getCompactMarkdownTheme());
|
|
215
|
+
const item: AssistantTranscriptItem = { kind: "assistant", text: value, streaming, component };
|
|
216
|
+
this.pushItem(item);
|
|
217
|
+
this.liveAssistant = streaming ? item : undefined;
|
|
218
|
+
return item;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private updateAssistant(item: AssistantTranscriptItem, text: string, streaming: boolean): void {
|
|
222
|
+
item.text = text;
|
|
223
|
+
item.streaming = streaming;
|
|
224
|
+
item.component.setText(text);
|
|
225
|
+
item.component.invalidate();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private addNotice(text: string, color: NoticeTranscriptItem["color"] = "dim"): void {
|
|
229
|
+
if (!text.trim()) return;
|
|
230
|
+
this.pushItem({ kind: "notice", text, color });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private createGroup(): CompactTranscriptItem {
|
|
234
|
+
const state: CompactExternalGroup = {
|
|
235
|
+
tools: [],
|
|
236
|
+
thinking: "",
|
|
237
|
+
thinkingActive: false,
|
|
238
|
+
sealed: false,
|
|
239
|
+
};
|
|
240
|
+
const item: CompactTranscriptItem = {
|
|
241
|
+
kind: "compact",
|
|
242
|
+
state,
|
|
243
|
+
component: new CompactExternalGroupComponent(state, this.theme),
|
|
244
|
+
};
|
|
245
|
+
item.component.setExpanded(this.expanded);
|
|
246
|
+
return item;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private ensureGroup(): CompactTranscriptItem {
|
|
250
|
+
if (this.currentGroup && !this.currentGroup.state.sealed) return this.currentGroup;
|
|
251
|
+
this.liveAssistant = undefined;
|
|
252
|
+
const item = this.createGroup();
|
|
253
|
+
this.pushItem(item);
|
|
254
|
+
this.currentGroup = item;
|
|
255
|
+
return item;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private sealCurrentGroup(): void {
|
|
259
|
+
if (!this.currentGroup) return;
|
|
260
|
+
this.currentGroup.state.sealed = true;
|
|
261
|
+
this.currentGroup.state.thinkingActive = false;
|
|
262
|
+
this.currentGroup = undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private restoreHistory(messages: any[]): void {
|
|
266
|
+
for (const message of messages) {
|
|
267
|
+
const role = message?.role;
|
|
268
|
+
if (role === "user") {
|
|
269
|
+
this.addUser(contentText(message.content));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (role === "assistant") {
|
|
273
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
274
|
+
if (block?.type === "thinking") {
|
|
275
|
+
const group = this.ensureGroup();
|
|
276
|
+
group.state.thinking += String(block.thinking ?? block.text ?? "");
|
|
277
|
+
group.state.thinkingTokens = estimateThinkingTokens(group.state.thinking);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (block?.type === "text" && String(block.text ?? "").trim()) {
|
|
281
|
+
this.addAssistant(String(block.text), false);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (block?.type === "toolCall") {
|
|
285
|
+
const group = this.ensureGroup();
|
|
286
|
+
const id = toolCallId(block);
|
|
287
|
+
if (!id || this.tools.has(id)) continue;
|
|
288
|
+
const tool: CompactExternalTool = {
|
|
289
|
+
id,
|
|
290
|
+
name: toolCallName(block),
|
|
291
|
+
args: toolCallArgs(block),
|
|
292
|
+
status: "pending",
|
|
293
|
+
resultText: "",
|
|
294
|
+
startedAt: Number(message.timestamp) || Date.now(),
|
|
295
|
+
};
|
|
296
|
+
group.state.tools.push(tool);
|
|
297
|
+
this.tools.set(id, tool);
|
|
298
|
+
this.toolGroups.set(id, group);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const reasoning = Number(message?.usage?.reasoning);
|
|
302
|
+
const group = this.currentGroup;
|
|
303
|
+
if (group?.state.thinking && Number.isFinite(reasoning) && reasoning > 0) {
|
|
304
|
+
group.state.thinkingTokens = reasoning;
|
|
305
|
+
group.state.thinkingTokensExact = true;
|
|
306
|
+
}
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (role === "toolResult") {
|
|
310
|
+
const id = toolCallId(message);
|
|
311
|
+
let tool = this.tools.get(id);
|
|
312
|
+
if (!tool) {
|
|
313
|
+
const group = this.ensureGroup();
|
|
314
|
+
tool = {
|
|
315
|
+
id: id || `history-tool-${this.tools.size}`,
|
|
316
|
+
name: toolCallName(message),
|
|
317
|
+
args: {},
|
|
318
|
+
status: "pending",
|
|
319
|
+
resultText: "",
|
|
320
|
+
startedAt: Number(message.timestamp) || Date.now(),
|
|
321
|
+
};
|
|
322
|
+
group.state.tools.push(tool);
|
|
323
|
+
this.tools.set(tool.id, tool);
|
|
324
|
+
this.toolGroups.set(tool.id, group);
|
|
325
|
+
}
|
|
326
|
+
tool.status = message.isError ? "error" : "success";
|
|
327
|
+
tool.resultText = toolResultText(message);
|
|
328
|
+
tool.endedAt = Number(message.timestamp) || Date.now();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private handleAssistantEnd(message: any): void {
|
|
334
|
+
const historicalThinking = thinkingText(message.content);
|
|
335
|
+
let reasoningGroup: CompactTranscriptItem | undefined;
|
|
336
|
+
if (historicalThinking) {
|
|
337
|
+
const assistantIndex = this.liveAssistant
|
|
338
|
+
? this.items.indexOf(this.liveAssistant)
|
|
339
|
+
: this.items.length;
|
|
340
|
+
const immediatelyBefore = this.items[assistantIndex - 1];
|
|
341
|
+
if (immediatelyBefore?.kind === "compact") {
|
|
342
|
+
reasoningGroup = immediatelyBefore;
|
|
343
|
+
} else if (this.liveAssistant && assistantIndex >= 0) {
|
|
344
|
+
reasoningGroup = this.createGroup();
|
|
345
|
+
reasoningGroup.state.sealed = true;
|
|
346
|
+
this.items.splice(assistantIndex, 0, reasoningGroup);
|
|
347
|
+
} else {
|
|
348
|
+
reasoningGroup = this.ensureGroup();
|
|
349
|
+
}
|
|
350
|
+
if (!reasoningGroup.state.thinking) {
|
|
351
|
+
reasoningGroup.state.thinking = historicalThinking;
|
|
352
|
+
reasoningGroup.state.thinkingTokens = estimateThinkingTokens(historicalThinking);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const authoritative = contentText(message.content);
|
|
356
|
+
if (this.liveAssistant) {
|
|
357
|
+
this.updateAssistant(this.liveAssistant, authoritative || this.liveAssistant.text, false);
|
|
358
|
+
this.liveAssistant = undefined;
|
|
359
|
+
} else if (authoritative.trim()) {
|
|
360
|
+
this.addAssistant(authoritative, false);
|
|
361
|
+
}
|
|
362
|
+
const reasoning = Number(message?.usage?.reasoning);
|
|
363
|
+
if (reasoningGroup?.state.thinking && Number.isFinite(reasoning) && reasoning > 0) {
|
|
364
|
+
reasoningGroup.state.thinkingTokens = reasoning;
|
|
365
|
+
reasoningGroup.state.thinkingTokensExact = true;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private handleEvent(event: any): void {
|
|
370
|
+
if (event?.type === "message_update") {
|
|
371
|
+
const update = event.assistantMessageEvent;
|
|
372
|
+
if (update?.type === "text_delta") {
|
|
373
|
+
if (!this.liveAssistant) this.addAssistant("", true);
|
|
374
|
+
if (this.liveAssistant) {
|
|
375
|
+
this.updateAssistant(
|
|
376
|
+
this.liveAssistant,
|
|
377
|
+
this.liveAssistant.text + String(update.delta ?? ""),
|
|
378
|
+
true,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
this.status = "responding…";
|
|
382
|
+
} else if (update?.type === "thinking_delta") {
|
|
383
|
+
const group = this.ensureGroup();
|
|
384
|
+
group.state.thinking += String(update.delta ?? "");
|
|
385
|
+
group.state.thinkingActive = true;
|
|
386
|
+
group.state.thinkingTokens = estimateThinkingTokens(group.state.thinking);
|
|
387
|
+
group.state.thinkingTokensExact = false;
|
|
388
|
+
this.status = "thinking…";
|
|
389
|
+
}
|
|
390
|
+
} else if (event?.type === "message_end" && event.message?.role === "assistant") {
|
|
391
|
+
this.handleAssistantEnd(event.message);
|
|
392
|
+
} else if (event?.type === "tool_execution_start") {
|
|
393
|
+
const id = String(event.toolCallId ?? "");
|
|
394
|
+
let tool = this.tools.get(id);
|
|
395
|
+
if (!tool) {
|
|
396
|
+
const group = this.ensureGroup();
|
|
397
|
+
tool = {
|
|
398
|
+
id,
|
|
399
|
+
name: event.toolName || "unknown",
|
|
400
|
+
args: event.args ?? {},
|
|
401
|
+
status: "pending",
|
|
402
|
+
resultText: "",
|
|
403
|
+
startedAt: Date.now(),
|
|
404
|
+
};
|
|
405
|
+
group.state.tools.push(tool);
|
|
406
|
+
this.tools.set(id, tool);
|
|
407
|
+
this.toolGroups.set(id, group);
|
|
408
|
+
} else {
|
|
409
|
+
tool.status = "pending";
|
|
410
|
+
tool.args = event.args ?? tool.args;
|
|
411
|
+
const group = this.toolGroups.get(id);
|
|
412
|
+
if (group && !group.state.sealed) this.currentGroup = group;
|
|
413
|
+
}
|
|
414
|
+
this.status = `${event.toolName || "tool"}…`;
|
|
415
|
+
} else if (event?.type === "tool_execution_update") {
|
|
416
|
+
const tool = this.tools.get(String(event.toolCallId ?? ""));
|
|
417
|
+
if (tool) {
|
|
418
|
+
tool.resultText = toolResultText(event.partialResult);
|
|
419
|
+
tool.status = "pending";
|
|
420
|
+
}
|
|
421
|
+
this.status = `${event.toolName || "tool"}…`;
|
|
422
|
+
} else if (event?.type === "tool_execution_end") {
|
|
423
|
+
const tool = this.tools.get(String(event.toolCallId ?? ""));
|
|
424
|
+
if (tool) {
|
|
425
|
+
tool.resultText = toolResultText(event.result);
|
|
426
|
+
tool.status = event.isError ? "error" : "success";
|
|
427
|
+
tool.endedAt = Date.now();
|
|
428
|
+
}
|
|
429
|
+
this.status = event.isError ? `${event.toolName || "tool"} failed` : "working…";
|
|
430
|
+
} else if (event?.type === "agent_start") {
|
|
431
|
+
this.status = "working…";
|
|
432
|
+
} else if (event?.type === "agent_end") {
|
|
433
|
+
this.status = "finishing…";
|
|
434
|
+
} else if (event?.type === "agent_settled") {
|
|
435
|
+
this.sealCurrentGroup();
|
|
436
|
+
this.status = "idle";
|
|
437
|
+
} else if (event?.type === "session_closed") {
|
|
438
|
+
this.sealCurrentGroup();
|
|
439
|
+
this.status = "session ended";
|
|
440
|
+
if (event.error) this.addNotice(`[session] ${event.error}`, "error");
|
|
441
|
+
}
|
|
442
|
+
this.tui.requestRender();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
pushEvent(event: any): void {
|
|
446
|
+
this.handleEvent(event);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private submit(value: string): void {
|
|
450
|
+
const message = value.trim();
|
|
451
|
+
if (!message) return;
|
|
452
|
+
this.input.setValue("");
|
|
453
|
+
this.addUser(message);
|
|
454
|
+
this.status = "sending…";
|
|
455
|
+
this.scrollFromBottom = 0;
|
|
456
|
+
this.tui.requestRender();
|
|
457
|
+
void this.session
|
|
458
|
+
.send(message)
|
|
459
|
+
.then(() => {
|
|
460
|
+
this.status = "accepted";
|
|
461
|
+
this.tui.requestRender();
|
|
462
|
+
})
|
|
463
|
+
.catch((error) => {
|
|
464
|
+
this.status = "send failed";
|
|
465
|
+
this.addNotice(`[error] ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
466
|
+
this.tui.requestRender();
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private close(): void {
|
|
471
|
+
if (this.closed) return;
|
|
472
|
+
this.closed = true;
|
|
473
|
+
this.restoreAltScreenScroll();
|
|
474
|
+
this.done();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private toggleExpanded(): void {
|
|
478
|
+
this.expanded = !this.expanded;
|
|
479
|
+
for (const item of this.items) {
|
|
480
|
+
if (item.kind === "compact") item.component.setExpanded(this.expanded);
|
|
481
|
+
}
|
|
482
|
+
this.tui.requestRender();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
handleInput(data: string): void {
|
|
486
|
+
if (matchesKey(data, "escape")) {
|
|
487
|
+
this.close();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (matchesKey(data, "ctrl+x")) {
|
|
491
|
+
this.status = "aborting…";
|
|
492
|
+
void this.session.abort().catch((error) => {
|
|
493
|
+
this.addNotice(`[abort error] ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
494
|
+
});
|
|
495
|
+
this.tui.requestRender();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (matchesKey(data, "ctrl+o")) {
|
|
499
|
+
this.toggleExpanded();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (matchesKey(data, "pageUp")) {
|
|
503
|
+
this.scrollFromBottom += Math.max(5, Math.floor((this.tui.terminal?.rows ?? 30) * 0.7));
|
|
504
|
+
this.tui.requestRender();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (matchesKey(data, "pageDown")) {
|
|
508
|
+
this.scrollFromBottom = Math.max(
|
|
509
|
+
0,
|
|
510
|
+
this.scrollFromBottom - Math.max(5, Math.floor((this.tui.terminal?.rows ?? 30) * 0.7)),
|
|
511
|
+
);
|
|
512
|
+
this.tui.requestRender();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (matchesKey(data, "home")) {
|
|
516
|
+
this.scrollFromBottom = Number.MAX_SAFE_INTEGER;
|
|
517
|
+
this.tui.requestRender();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (matchesKey(data, "end")) {
|
|
521
|
+
this.scrollFromBottom = 0;
|
|
522
|
+
this.tui.requestRender();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
this.input.handleInput(data);
|
|
526
|
+
this.tui.requestRender();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private frameLine(content: string, innerWidth: number, border: (text: string) => string): string {
|
|
530
|
+
const clipped = truncateToWidth(content, innerWidth, "…");
|
|
531
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
532
|
+
return `${border("│")}${clipped}${padding}${border("│")}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private renderTranscript(width: number): string[] {
|
|
536
|
+
const lines: string[] = [];
|
|
537
|
+
for (const item of this.items) {
|
|
538
|
+
if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
|
|
539
|
+
if (item.kind === "user" || item.kind === "assistant" || item.kind === "compact") {
|
|
540
|
+
lines.push(...item.component.render(width));
|
|
541
|
+
} else {
|
|
542
|
+
lines.push(this.theme.fg(item.color, item.text));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return lines;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
render(width: number): string[] {
|
|
549
|
+
const safeWidth = Math.max(20, width);
|
|
550
|
+
const innerWidth = Math.max(1, safeWidth - 2);
|
|
551
|
+
const border = (text: string) => this.theme.fg("borderAccent", text);
|
|
552
|
+
const terminalRows = Math.max(18, Number(this.tui.terminal?.rows) || 30);
|
|
553
|
+
const targetHeight = Math.max(16, Math.min(terminalRows - 2, Math.floor(terminalRows * 0.85)));
|
|
554
|
+
const fixedRows = 5;
|
|
555
|
+
const transcriptHeight = Math.max(8, targetHeight - fixedRows);
|
|
556
|
+
const transcript = this.renderTranscript(innerWidth);
|
|
557
|
+
const maxScroll = Math.max(0, transcript.length - transcriptHeight);
|
|
558
|
+
this.scrollFromBottom = Math.min(this.scrollFromBottom, maxScroll);
|
|
559
|
+
const end = Math.max(0, transcript.length - this.scrollFromBottom);
|
|
560
|
+
const start = Math.max(0, end - transcriptHeight);
|
|
561
|
+
const visible = transcript.slice(start, end);
|
|
562
|
+
const hiddenAbove = start;
|
|
563
|
+
const hiddenBelow = transcript.length - end;
|
|
564
|
+
const scrollLabel =
|
|
565
|
+
hiddenAbove || hiddenBelow ? ` · ↑${hiddenAbove} ↓${hiddenBelow}` : "";
|
|
566
|
+
const title = ` ${this.theme.bold(this.theme.fg("accent", this.session.agent))} session ${this.theme.fg("muted", `· ${this.status}${scrollLabel}`)} `;
|
|
567
|
+
const titleWidth = visibleWidth(title);
|
|
568
|
+
const titleRight = Math.max(0, innerWidth - titleWidth);
|
|
569
|
+
const lines = [
|
|
570
|
+
`${border("╭")}${title}${border(`${"─".repeat(titleRight)}╮`)}`,
|
|
571
|
+
];
|
|
572
|
+
for (const line of visible) lines.push(this.frameLine(line, innerWidth, border));
|
|
573
|
+
while (lines.length < transcriptHeight + 1) lines.push(this.frameLine("", innerWidth, border));
|
|
574
|
+
lines.push(`${border("├")}${border("─".repeat(innerWidth))}${border("┤")}`);
|
|
575
|
+
const [inputLine = ""] = this.input.render(Math.max(1, innerWidth - 4));
|
|
576
|
+
const inputContent = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
|
|
577
|
+
lines.push(this.frameLine(` › ${inputContent}`, innerWidth, border));
|
|
578
|
+
lines.push(
|
|
579
|
+
this.frameLine(
|
|
580
|
+
` ${this.theme.fg("dim", "Enter send · Esc main · Ctrl+X abort · Ctrl+O expand · PgUp/PgDn scroll")}`,
|
|
581
|
+
innerWidth,
|
|
582
|
+
border,
|
|
583
|
+
),
|
|
584
|
+
);
|
|
585
|
+
lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
|
|
586
|
+
return lines.map((line) => truncateToWidth(line, safeWidth, ""));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
invalidate(): void {
|
|
590
|
+
this.input.invalidate();
|
|
591
|
+
for (const item of this.items) {
|
|
592
|
+
if ("component" in item) item.component.invalidate();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
dispose(): void {
|
|
597
|
+
if (this.disposed) return;
|
|
598
|
+
this.disposed = true;
|
|
599
|
+
this.restoreAltScreenScroll();
|
|
600
|
+
clearInterval(this.animationTimer);
|
|
601
|
+
this.unsubscribe();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function openSubagentSessionOverlay(ctx: any, session: SubagentSessionHandle): Promise<void> {
|
|
606
|
+
const bufferedEvents: any[] = [];
|
|
607
|
+
let component: SubagentSessionComponent | undefined;
|
|
608
|
+
const unsubscribe = session.subscribe((event) => {
|
|
609
|
+
if (component) component.pushEvent(event);
|
|
610
|
+
else bufferedEvents.push(event);
|
|
611
|
+
});
|
|
612
|
+
let messages: any[] = [];
|
|
613
|
+
try {
|
|
614
|
+
messages = await session.getMessages();
|
|
615
|
+
} catch {
|
|
616
|
+
/* a just-starting process may not have history yet */
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
await ctx.ui.custom<void>(
|
|
620
|
+
(tui: any, theme: any, keybindings: any, done: () => void) => {
|
|
621
|
+
component = new SubagentSessionComponent(
|
|
622
|
+
tui,
|
|
623
|
+
theme,
|
|
624
|
+
session,
|
|
625
|
+
messages,
|
|
626
|
+
done,
|
|
627
|
+
unsubscribe,
|
|
628
|
+
keybindings,
|
|
629
|
+
);
|
|
630
|
+
for (const event of bufferedEvents.splice(0)) component.pushEvent(event);
|
|
631
|
+
return component;
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
overlay: true,
|
|
635
|
+
overlayOptions: {
|
|
636
|
+
width: "96%",
|
|
637
|
+
minWidth: 50,
|
|
638
|
+
maxHeight: "85%",
|
|
639
|
+
anchor: "center",
|
|
640
|
+
margin: 0,
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (!component) unsubscribe();
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
648
|
+
}
|
package/session.ts
ADDED