@esso0428/pi-subagents 0.15.16 → 0.16.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/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/dist/agent-history-list.d.ts.map +1 -1
- package/dist/agent-history-list.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -6
- package/dist/index.js.map +1 -1
- package/dist/output-file.js +3 -3
- package/dist/output-file.js.map +1 -1
- package/dist/ui/agent-widget.d.ts +11 -4
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +85 -19
- package/dist/ui/agent-widget.js.map +1 -1
- package/dist/ui/ccstyle/diff/ansi-utils.js +2 -2
- package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -1
- package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -1
- package/dist/ui/ccstyle/diff/diff-presentation.js +0 -1
- package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.js +15 -10
- package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
- package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -1
- package/dist/ui/ccstyle/diff/line-width-safety.js +1 -2
- package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -1
- package/dist/ui/ccstyle/diff/shiki-highlight.d.ts +19 -0
- package/dist/ui/ccstyle/diff/shiki-highlight.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/shiki-highlight.js +85 -0
- package/dist/ui/ccstyle/diff/shiki-highlight.js.map +1 -0
- package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
- package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.js +1 -1
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-result.d.ts +9 -1
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-result.js +137 -5
- package/dist/ui/ccstyle/tool-result.js.map +1 -1
- package/dist/ui/conversation-blocks.d.ts.map +1 -1
- package/dist/ui/conversation-blocks.js +14 -13
- package/dist/ui/conversation-blocks.js.map +1 -1
- package/dist/ui/conversation-role.d.ts +1 -1
- package/dist/ui/conversation-role.d.ts.map +1 -1
- package/dist/ui/conversation-search.js +4 -4
- package/dist/ui/conversation-search.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +17 -1
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +116 -43
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +5 -1
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +67 -26
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/dist/ui/deferred-ui-refresh.d.ts +15 -0
- package/dist/ui/deferred-ui-refresh.d.ts.map +1 -0
- package/dist/ui/deferred-ui-refresh.js +50 -0
- package/dist/ui/deferred-ui-refresh.js.map +1 -0
- package/dist/ui/fleet-list.d.ts +55 -14
- package/dist/ui/fleet-list.d.ts.map +1 -1
- package/dist/ui/fleet-list.js +178 -44
- package/dist/ui/fleet-list.js.map +1 -1
- package/dist/write-execution.d.ts.map +1 -1
- package/dist/write-execution.js +1 -1
- package/dist/write-execution.js.map +1 -1
- package/package.json +6 -5
- package/src/agent-history-list.ts +1 -1
- package/src/index.ts +49 -7
- package/src/output-file.ts +3 -3
- package/src/ui/agent-widget.ts +18 -10
- package/src/ui/ccstyle/diff/ansi-utils.ts +2 -2
- package/src/ui/ccstyle/diff/diff-presentation.ts +0 -1
- package/src/ui/ccstyle/diff/diff-renderer.ts +22 -21
- package/src/ui/ccstyle/diff/line-width-safety.ts +1 -2
- package/src/ui/ccstyle/diff/shiki-highlight.ts +97 -0
- package/src/ui/ccstyle/tool-renderer.ts +3 -3
- package/src/ui/ccstyle/tool-result.ts +175 -7
- package/src/ui/conversation-role.ts +1 -1
- package/src/ui/conversation-search.ts +4 -4
- package/src/ui/conversation-timeline.ts +7 -7
- package/src/ui/conversation-viewer.ts +16 -7
- package/src/ui/deferred-ui-refresh.ts +72 -0
- package/src/ui/fleet-list.ts +185 -50
- package/src/write-execution.ts +2 -2
- package/test/agent-history-list.test.ts +0 -121
- package/test/agent-manager-history.test.ts +0 -88
- package/test/agent-menu-history-navigation.test.ts +0 -86
- package/test/output-file-history.test.ts +0 -50
- package/test/ui/agent-widget.test.ts +0 -161
- package/test/ui/conversation-blocks.test.ts +0 -103
- package/test/ui/conversation-diff.test.ts +0 -91
- package/test/ui/conversation-search.test.ts +0 -55
- package/test/ui/conversation-viewer.test.ts +0 -438
- package/test/ui/fleet-list.test.ts +0 -135
- package/test/write-execution.test.ts +0 -83
- package/tsconfig.json +0 -16
|
@@ -1,438 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import type { TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { ConversationViewer, createStaticConversationSource } from "../../src/ui/conversation-viewer.js";
|
|
4
|
-
|
|
5
|
-
const theme = {
|
|
6
|
-
fg: (name: string, text: string) => name === "text" ? `\x1b[36m${text}\x1b[39m` : text,
|
|
7
|
-
bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
function record(extra: Record<string, unknown> = {}): any {
|
|
11
|
-
return {
|
|
12
|
-
id: "agent-1",
|
|
13
|
-
type: "general-purpose",
|
|
14
|
-
description: "test agent",
|
|
15
|
-
status: "completed",
|
|
16
|
-
toolUses: 0,
|
|
17
|
-
startedAt: 0,
|
|
18
|
-
lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
19
|
-
...extra,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function tui() {
|
|
24
|
-
return {
|
|
25
|
-
terminal: { rows: 30 },
|
|
26
|
-
requestRender: vi.fn(),
|
|
27
|
-
stop: vi.fn(),
|
|
28
|
-
start: vi.fn(),
|
|
29
|
-
} as any;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function mouse(type: TuiMouseEvent["type"], y: number, overrides: Partial<TuiMouseEvent> = {}): TuiMouseEvent {
|
|
33
|
-
return {
|
|
34
|
-
type,
|
|
35
|
-
button: "left",
|
|
36
|
-
x: 2,
|
|
37
|
-
y,
|
|
38
|
-
screenX: 2,
|
|
39
|
-
screenY: y,
|
|
40
|
-
width: 120,
|
|
41
|
-
height: 30,
|
|
42
|
-
shift: false,
|
|
43
|
-
alt: false,
|
|
44
|
-
ctrl: false,
|
|
45
|
-
...overrides,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// The test record has the default invocation row, so timeline line 0 is viewer row 4.
|
|
50
|
-
function viewerY(timelineLine: number): number {
|
|
51
|
-
return 4 + timelineLine;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
describe("ConversationViewer", () => {
|
|
55
|
-
it("renders assistant fenced code through the Markdown renderer", () => {
|
|
56
|
-
const ui = tui();
|
|
57
|
-
const viewer = new ConversationViewer(
|
|
58
|
-
ui,
|
|
59
|
-
createStaticConversationSource([
|
|
60
|
-
{ role: "assistant", content: [{ type: "text", text: "```ts\nconst answer: number = 42;\n```" }] },
|
|
61
|
-
] as any),
|
|
62
|
-
record(),
|
|
63
|
-
undefined,
|
|
64
|
-
theme,
|
|
65
|
-
vi.fn(),
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
const rendered = viewer.render(100).join("\n");
|
|
69
|
-
expect(rendered).toContain("answer");
|
|
70
|
-
expect(rendered).toMatch(/\x1b\[/);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("toggles compact tool rows without changing the transcript", () => {
|
|
74
|
-
const ui = tui();
|
|
75
|
-
const viewer = new ConversationViewer(
|
|
76
|
-
ui,
|
|
77
|
-
createStaticConversationSource([
|
|
78
|
-
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "src/index.ts" } }] },
|
|
79
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "contents" }] },
|
|
80
|
-
] as any),
|
|
81
|
-
record(),
|
|
82
|
-
undefined,
|
|
83
|
-
theme,
|
|
84
|
-
vi.fn(),
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
expect(viewer.render(100).join("\n")).toContain("read");
|
|
88
|
-
viewer.handleInput("t");
|
|
89
|
-
expect(viewer.render(100).join("\n")).not.toContain("contents");
|
|
90
|
-
viewer.handleInput("t");
|
|
91
|
-
expect(viewer.render(100).join("\n")).toContain("contents");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("shows pi-peek hints for live viewers and hides O in history", () => {
|
|
95
|
-
const live = new ConversationViewer(
|
|
96
|
-
tui(),
|
|
97
|
-
createStaticConversationSource([{ role: "assistant", content: "live" }] as any),
|
|
98
|
-
record({ status: "running", session: {} }),
|
|
99
|
-
undefined,
|
|
100
|
-
theme,
|
|
101
|
-
vi.fn(),
|
|
102
|
-
vi.fn(),
|
|
103
|
-
undefined,
|
|
104
|
-
vi.fn(),
|
|
105
|
-
{ pi: {} as any, ctx: {} as any, readOnly: false },
|
|
106
|
-
);
|
|
107
|
-
const liveFooter = live.render(120).join("\n");
|
|
108
|
-
expect(liveFooter).toContain("j/k scroll · J/K messages · [/] tools · w preview · g/G t M o O /n N q");
|
|
109
|
-
expect(liveFooter).toContain("e steer");
|
|
110
|
-
expect(liveFooter).toContain("x stop");
|
|
111
|
-
live.handleInput("e");
|
|
112
|
-
expect(live.render(120).join("\n")).toContain("Enter send");
|
|
113
|
-
live.handleInput("\x1b");
|
|
114
|
-
|
|
115
|
-
const history = new ConversationViewer(
|
|
116
|
-
tui(),
|
|
117
|
-
createStaticConversationSource([{ role: "assistant", content: "history" }] as any),
|
|
118
|
-
record({ session: {} }),
|
|
119
|
-
undefined,
|
|
120
|
-
theme,
|
|
121
|
-
vi.fn(),
|
|
122
|
-
undefined,
|
|
123
|
-
undefined,
|
|
124
|
-
undefined,
|
|
125
|
-
{ pi: {} as any, ctx: {} as any, readOnly: true },
|
|
126
|
-
);
|
|
127
|
-
const historyFooter = history.render(120).join("\n");
|
|
128
|
-
expect(historyFooter).toContain("j/k scroll · J/K messages · [/] tools · w preview · g/G t M o /n N q");
|
|
129
|
-
expect(historyFooter).not.toContain("M o O");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("shows semantic role headers, effective invocation metadata, and the search row", () => {
|
|
133
|
-
const semanticTheme = {
|
|
134
|
-
fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
|
|
135
|
-
bold: (text: string) => `<bold>${text}</bold>`,
|
|
136
|
-
};
|
|
137
|
-
const viewer = new ConversationViewer(
|
|
138
|
-
tui(),
|
|
139
|
-
createStaticConversationSource([
|
|
140
|
-
{ role: "user", content: "find model" },
|
|
141
|
-
{ role: "assistant", content: [{ type: "text", text: "model found" }] },
|
|
142
|
-
] as any),
|
|
143
|
-
record({ invocation: { effectiveModelName: "claude-sonnet", effectiveThinking: "high" } }),
|
|
144
|
-
undefined,
|
|
145
|
-
semanticTheme,
|
|
146
|
-
vi.fn(),
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
const initial = viewer.render(120).join("\n");
|
|
150
|
-
expect(initial).toContain("<userMessageText>USER</userMessageText>");
|
|
151
|
-
expect(initial).toContain("<accent>ASSISTANT</accent>");
|
|
152
|
-
expect(initial).toContain("model: claude-sonnet");
|
|
153
|
-
expect(initial).toContain("thinking: high");
|
|
154
|
-
|
|
155
|
-
viewer.handleInput("/");
|
|
156
|
-
const searchRow = viewer.render(120).join("\n");
|
|
157
|
-
expect(searchRow).toContain("<accent>/</accent>");
|
|
158
|
-
expect(searchRow).toContain("Enter apply · Esc cancel");
|
|
159
|
-
for (const character of "model") viewer.handleInput(character);
|
|
160
|
-
expect(viewer.render(120).join("\n")).toContain("model");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("focuses and expands a bounded fallback tool card", () => {
|
|
164
|
-
const viewer = new ConversationViewer(
|
|
165
|
-
tui(),
|
|
166
|
-
createStaticConversationSource([
|
|
167
|
-
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
|
|
168
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
|
|
169
|
-
] as any),
|
|
170
|
-
record(),
|
|
171
|
-
undefined,
|
|
172
|
-
theme,
|
|
173
|
-
vi.fn(),
|
|
174
|
-
);
|
|
175
|
-
|
|
176
|
-
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
177
|
-
viewer.handleInput("\t");
|
|
178
|
-
viewer.handleInput("\r");
|
|
179
|
-
expect(viewer.render(120).join("\n")).toContain("six");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("toggles mouse expansion only on the normalized click event", () => {
|
|
183
|
-
const viewer = new ConversationViewer(
|
|
184
|
-
tui(),
|
|
185
|
-
createStaticConversationSource([
|
|
186
|
-
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
|
|
187
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
|
|
188
|
-
] as any),
|
|
189
|
-
record(),
|
|
190
|
-
undefined,
|
|
191
|
-
theme,
|
|
192
|
-
vi.fn(),
|
|
193
|
-
);
|
|
194
|
-
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
195
|
-
const span = (viewer as any).timeline.getSnapshot().toolSpans[0];
|
|
196
|
-
expect(span.clickStartLine).toEqual(expect.any(Number));
|
|
197
|
-
const hintY = viewerY(span.clickStartLine);
|
|
198
|
-
expect(viewer.handleMouse(mouse("press", hintY))?.handled).toBe(true);
|
|
199
|
-
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
200
|
-
viewer.handleMouse(mouse("click", hintY, { clickCount: 1 }));
|
|
201
|
-
expect(viewer.render(120).join("\n")).toContain("six");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("focuses USER and ASSISTANT blocks from their rendered spans", () => {
|
|
205
|
-
const viewer = new ConversationViewer(
|
|
206
|
-
tui(),
|
|
207
|
-
createStaticConversationSource([
|
|
208
|
-
{ role: "user", content: "user text" },
|
|
209
|
-
{ role: "assistant", content: [{ type: "text", text: "assistant text" }] },
|
|
210
|
-
] as any),
|
|
211
|
-
record(),
|
|
212
|
-
undefined,
|
|
213
|
-
theme,
|
|
214
|
-
vi.fn(),
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
viewer.render(120);
|
|
218
|
-
const timeline = (viewer as any).timeline;
|
|
219
|
-
const spans = timeline.getSnapshot().blockSpans;
|
|
220
|
-
expect(spans).toHaveLength(2);
|
|
221
|
-
viewer.handleMouse(mouse("click", viewerY(spans[1].startLine), { clickCount: 1 }));
|
|
222
|
-
expect((viewer as any).selectedMessageIdx).toBe(1);
|
|
223
|
-
expect(timeline.getFocusedToolCallId()).toBeUndefined();
|
|
224
|
-
viewer.handleMouse(mouse("click", viewerY(spans[0].startLine), { clickCount: 1 }));
|
|
225
|
-
expect((viewer as any).selectedMessageIdx).toBe(0);
|
|
226
|
-
expect(timeline.getFocusedToolCallId()).toBeUndefined();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("focuses normal tool rows without expanding, while hint clicks expand", () => {
|
|
230
|
-
const viewer = new ConversationViewer(
|
|
231
|
-
tui(),
|
|
232
|
-
createStaticConversationSource([
|
|
233
|
-
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
|
|
234
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
|
|
235
|
-
] as any),
|
|
236
|
-
record(),
|
|
237
|
-
undefined,
|
|
238
|
-
theme,
|
|
239
|
-
vi.fn(),
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
243
|
-
const timeline = (viewer as any).timeline;
|
|
244
|
-
const initialSpan = timeline.getSnapshot().toolSpans[0];
|
|
245
|
-
const toolId = initialSpan.toolCallId;
|
|
246
|
-
const hintLine = initialSpan.clickStartLine as number;
|
|
247
|
-
const normalOutputLine = hintLine - 1;
|
|
248
|
-
|
|
249
|
-
const press = viewer.handleMouse(mouse("press", viewerY(hintLine)));
|
|
250
|
-
expect(press).toMatchObject({ handled: true, capture: true });
|
|
251
|
-
expect(timeline.getFocusedToolCallId()).toBeUndefined();
|
|
252
|
-
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
253
|
-
|
|
254
|
-
viewer.handleMouse(mouse("click", viewerY(normalOutputLine), { clickCount: 1 }));
|
|
255
|
-
expect(timeline.getFocusedToolCallId()).toBe(toolId);
|
|
256
|
-
expect((viewer as any).selectedMessageIdx).toBe(initialSpan.blockIndex);
|
|
257
|
-
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
258
|
-
|
|
259
|
-
const collapsedSpan = timeline.getSnapshot().toolSpans[0];
|
|
260
|
-
viewer.handleMouse(mouse("click", viewerY(collapsedSpan.clickStartLine as number), { clickCount: 1 }));
|
|
261
|
-
expect(timeline.getFocusedToolCallId()).toBe(toolId);
|
|
262
|
-
expect(viewer.render(120).join("\n")).toContain("six");
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it("synchronizes J/K message focus and anchors [/] around the selected message", () => {
|
|
266
|
-
const viewer = new ConversationViewer(
|
|
267
|
-
tui(),
|
|
268
|
-
createStaticConversationSource([
|
|
269
|
-
{ role: "user", content: "user one" },
|
|
270
|
-
{ role: "assistant", content: [{ type: "text", text: "assistant one" }] },
|
|
271
|
-
{ role: "assistant", content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "one" } }] },
|
|
272
|
-
{ role: "toolResult", toolCallId: "tool-1", toolName: "read", content: [{ type: "text", text: "one" }] },
|
|
273
|
-
{ role: "assistant", content: [{ type: "text", text: "assistant two" }] },
|
|
274
|
-
{ role: "assistant", content: [{ type: "toolCall", id: "tool-2", name: "read", arguments: { path: "two" } }] },
|
|
275
|
-
{ role: "toolResult", toolCallId: "tool-2", toolName: "read", content: [{ type: "text", text: "two" }] },
|
|
276
|
-
] as any),
|
|
277
|
-
record(),
|
|
278
|
-
undefined,
|
|
279
|
-
theme,
|
|
280
|
-
vi.fn(),
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
viewer.render(120);
|
|
284
|
-
const timeline = (viewer as any).timeline;
|
|
285
|
-
const snapshot = timeline.getSnapshot();
|
|
286
|
-
viewer.handleInput("]");
|
|
287
|
-
expect((viewer as any).selectedMessageIdx).toBe(snapshot.toolSpans[0].blockIndex);
|
|
288
|
-
expect(timeline.getFocusedToolCallId()).toBe("tool-1");
|
|
289
|
-
|
|
290
|
-
viewer.handleInput("J");
|
|
291
|
-
expect((viewer as any).selectedMessageIdx).toBe(3);
|
|
292
|
-
expect(timeline.getFocusedToolCallId()).toBeUndefined();
|
|
293
|
-
viewer.handleInput("]");
|
|
294
|
-
expect((viewer as any).selectedMessageIdx).toBe(snapshot.toolSpans[1].blockIndex);
|
|
295
|
-
expect(timeline.getFocusedToolCallId()).toBe("tool-2");
|
|
296
|
-
|
|
297
|
-
viewer.handleInput("K");
|
|
298
|
-
expect((viewer as any).selectedMessageIdx).toBe(3);
|
|
299
|
-
expect(timeline.getFocusedToolCallId()).toBeUndefined();
|
|
300
|
-
viewer.handleInput("[");
|
|
301
|
-
expect((viewer as any).selectedMessageIdx).toBe(snapshot.toolSpans[0].blockIndex);
|
|
302
|
-
expect(timeline.getFocusedToolCallId()).toBe("tool-1");
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it("uses the first or last tool for initial [/], before an explicit focus exists", () => {
|
|
306
|
-
const source = createStaticConversationSource([
|
|
307
|
-
{ role: "assistant", content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "one" } }] },
|
|
308
|
-
{ role: "toolResult", toolCallId: "tool-1", toolName: "read", content: [{ type: "text", text: "one" }] },
|
|
309
|
-
{ role: "assistant", content: [{ type: "toolCall", id: "tool-2", name: "read", arguments: { path: "two" } }] },
|
|
310
|
-
{ role: "toolResult", toolCallId: "tool-2", toolName: "read", content: [{ type: "text", text: "two" }] },
|
|
311
|
-
] as any);
|
|
312
|
-
const makeViewer = () => new ConversationViewer(tui(), source, record(), undefined, theme, vi.fn());
|
|
313
|
-
|
|
314
|
-
const left = makeViewer();
|
|
315
|
-
left.render(120);
|
|
316
|
-
left.handleInput("[");
|
|
317
|
-
expect((left as any).timeline.getFocusedToolCallId()).toBe("tool-2");
|
|
318
|
-
|
|
319
|
-
const right = makeViewer();
|
|
320
|
-
right.render(120);
|
|
321
|
-
right.handleInput("]");
|
|
322
|
-
expect((right as any).timeline.getFocusedToolCallId()).toBe("tool-1");
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it("highlights the collapsed tool hint and the viewer close control independently", () => {
|
|
326
|
-
const semanticTheme = {
|
|
327
|
-
fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
|
|
328
|
-
bold: (text: string) => `<bold>${text}</bold>`,
|
|
329
|
-
};
|
|
330
|
-
const done = vi.fn();
|
|
331
|
-
const viewer = new ConversationViewer(
|
|
332
|
-
tui(),
|
|
333
|
-
createStaticConversationSource([
|
|
334
|
-
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
|
|
335
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
|
|
336
|
-
] as any),
|
|
337
|
-
record(),
|
|
338
|
-
undefined,
|
|
339
|
-
semanticTheme,
|
|
340
|
-
done,
|
|
341
|
-
);
|
|
342
|
-
const initial = viewer.render(120).join("\n");
|
|
343
|
-
const span = (viewer as any).timeline.getSnapshot().toolSpans[0];
|
|
344
|
-
const hintY = viewerY(span.clickStartLine);
|
|
345
|
-
expect(initial).not.toContain("<text>click to show more</text>");
|
|
346
|
-
viewer.handleMouse(mouse("move", hintY, { button: "none" }));
|
|
347
|
-
expect(viewer.render(120).join("\n")).toContain("<text>click to show more</text>");
|
|
348
|
-
viewer.handleMouse(mouse("move", 1, { button: "none", x: 114, screenX: 114, screenY: 1 }));
|
|
349
|
-
expect(viewer.render(120).join("\n")).toContain("<text><bold>[esc]</bold></text>");
|
|
350
|
-
expect(viewer.handleMouse(mouse("press", 1, { x: 114, screenX: 114 }))?.handled).toBe(true);
|
|
351
|
-
expect(viewer.handleMouse(mouse("click", 1, { x: 114, screenX: 114, clickCount: 1 }))?.handled).toBe(true);
|
|
352
|
-
expect(done).toHaveBeenCalledTimes(1);
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it("focuses nearby tools with [ and ] and opens a complete read-only preview with w", () => {
|
|
356
|
-
const ui = tui();
|
|
357
|
-
let preview: any;
|
|
358
|
-
const previewDone = vi.fn();
|
|
359
|
-
const custom = vi.fn((factory: any) => {
|
|
360
|
-
preview = factory(ui, theme, undefined, previewDone);
|
|
361
|
-
const initial = preview.render(100).join("\\n");
|
|
362
|
-
expect(initial).toContain("Input");
|
|
363
|
-
expect(initial).toContain("Output");
|
|
364
|
-
expect(initial).toContain("line 1");
|
|
365
|
-
expect(initial).not.toContain("line 40");
|
|
366
|
-
preview.handleInput("G");
|
|
367
|
-
expect(preview.render(100).join("\\n")).toContain("line 40");
|
|
368
|
-
preview.handleInput("k");
|
|
369
|
-
preview.handleInput("j");
|
|
370
|
-
preview.handleInput("g");
|
|
371
|
-
preview.handleInput("g");
|
|
372
|
-
expect(preview.render(100).join("\\n")).toContain("line 1");
|
|
373
|
-
preview.handleInput("q");
|
|
374
|
-
expect(previewDone).toHaveBeenCalledTimes(1);
|
|
375
|
-
return Promise.resolve(undefined);
|
|
376
|
-
});
|
|
377
|
-
const viewer = new ConversationViewer(
|
|
378
|
-
ui,
|
|
379
|
-
createStaticConversationSource([
|
|
380
|
-
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "first" } }] },
|
|
381
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n") }] },
|
|
382
|
-
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "second" } }] },
|
|
383
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "second output" }] },
|
|
384
|
-
] as any),
|
|
385
|
-
record(),
|
|
386
|
-
undefined,
|
|
387
|
-
theme,
|
|
388
|
-
vi.fn(),
|
|
389
|
-
undefined,
|
|
390
|
-
undefined,
|
|
391
|
-
undefined,
|
|
392
|
-
{ pi: {} as any, ctx: { ui: { custom } } as any, readOnly: true },
|
|
393
|
-
);
|
|
394
|
-
viewer.render(120);
|
|
395
|
-
const timeline = (viewer as any).timeline;
|
|
396
|
-
const ids = timeline.getSnapshot().interactiveToolIds;
|
|
397
|
-
viewer.handleInput("]");
|
|
398
|
-
expect(timeline.getFocusedToolCallId()).toBe(ids[0]);
|
|
399
|
-
viewer.render(120);
|
|
400
|
-
viewer.handleInput("w");
|
|
401
|
-
expect(custom).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ overlay: true }));
|
|
402
|
-
preview.handleInput("G");
|
|
403
|
-
preview.handleInput("g");
|
|
404
|
-
preview.handleInput("g");
|
|
405
|
-
preview.handleInput("q");
|
|
406
|
-
viewer.handleInput("]");
|
|
407
|
-
expect(timeline.getFocusedToolCallId()).toBe(ids[1]);
|
|
408
|
-
viewer.handleInput("[");
|
|
409
|
-
expect(timeline.getFocusedToolCallId()).toBe(ids[0]);
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
it("keeps historical viewers read-only and closes on Escape", () => {
|
|
413
|
-
const ui = tui();
|
|
414
|
-
const steer = vi.fn();
|
|
415
|
-
const stop = vi.fn();
|
|
416
|
-
const done = vi.fn();
|
|
417
|
-
const viewer = new ConversationViewer(
|
|
418
|
-
ui,
|
|
419
|
-
createStaticConversationSource([{ role: "assistant", content: "history" }] as any),
|
|
420
|
-
record({ status: "running", session: {} }),
|
|
421
|
-
undefined,
|
|
422
|
-
theme,
|
|
423
|
-
done,
|
|
424
|
-
stop,
|
|
425
|
-
undefined,
|
|
426
|
-
steer,
|
|
427
|
-
{ pi: {} as any, ctx: {} as any, readOnly: true },
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
viewer.handleInput("\r");
|
|
431
|
-
viewer.handleInput("x");
|
|
432
|
-
viewer.handleInput("O");
|
|
433
|
-
expect(steer).not.toHaveBeenCalled();
|
|
434
|
-
expect(stop).not.toHaveBeenCalled();
|
|
435
|
-
viewer.handleInput("\x1b");
|
|
436
|
-
expect(done).toHaveBeenCalledTimes(1);
|
|
437
|
-
});
|
|
438
|
-
});
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
calculateFleetAgentWindow,
|
|
4
|
-
FLEET_MAX_RENDER_ROWS,
|
|
5
|
-
FleetList,
|
|
6
|
-
} from "../../src/ui/fleet-list.js";
|
|
7
|
-
import { canOpenActiveAgent } from "../../src/agent-history-list.js";
|
|
8
|
-
|
|
9
|
-
const theme = {
|
|
10
|
-
fg: (_color: string, text: string) => text,
|
|
11
|
-
bold: (text: string) => text,
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
function makeAgents(count: number): any[] {
|
|
15
|
-
return Array.from({ length: count }, (_, i) => ({
|
|
16
|
-
id: `agent-${i}`,
|
|
17
|
-
type: "Explore",
|
|
18
|
-
description: `agent ${i}`,
|
|
19
|
-
status: "running",
|
|
20
|
-
session: { messages: [], subscribe: () => () => {} },
|
|
21
|
-
toolUses: 0,
|
|
22
|
-
startedAt: i + 1,
|
|
23
|
-
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
24
|
-
compactionCount: 0,
|
|
25
|
-
}));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function createFleetHarness(records: any[]) {
|
|
29
|
-
const manager: any = {
|
|
30
|
-
records,
|
|
31
|
-
listAgents() { return this.records; },
|
|
32
|
-
abort: vi.fn(() => false),
|
|
33
|
-
steer: vi.fn(() => false),
|
|
34
|
-
};
|
|
35
|
-
const tui = { terminal: { rows: 24 }, requestRender: vi.fn() } as any;
|
|
36
|
-
let terminalInput: ((data: string) => { consume?: boolean } | undefined) | undefined;
|
|
37
|
-
let factory: any;
|
|
38
|
-
const ui: any = {
|
|
39
|
-
setWidget: vi.fn((_key: string, content: any) => { factory = content; }),
|
|
40
|
-
onTerminalInput: (handler: typeof terminalInput) => {
|
|
41
|
-
terminalInput = handler;
|
|
42
|
-
return () => { terminalInput = undefined; };
|
|
43
|
-
},
|
|
44
|
-
getEditorText: () => "",
|
|
45
|
-
notify: vi.fn(),
|
|
46
|
-
custom: vi.fn(async () => undefined),
|
|
47
|
-
};
|
|
48
|
-
const fleet = new FleetList(manager, new Map());
|
|
49
|
-
fleet.setUICtx(ui);
|
|
50
|
-
fleet.update();
|
|
51
|
-
const render = (width = 120) => factory ? factory(tui, theme).render(width) : [];
|
|
52
|
-
const input = (data: string) => terminalInput?.(data);
|
|
53
|
-
render(); // The real TUI invokes the widget factory before keyboard input arrives.
|
|
54
|
-
return { manager, fleet, tui, ui, render, input };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
describe("canOpenActiveAgent", () => {
|
|
58
|
-
it("does not treat terminal records as active FleetView rows", () => {
|
|
59
|
-
expect(canOpenActiveAgent({ status: "completed", session: {} } as any)).toBe(false);
|
|
60
|
-
expect(canOpenActiveAgent({ status: "error", session: {} } as any)).toBe(false);
|
|
61
|
-
expect(canOpenActiveAgent({ status: "running", session: {} } as any)).toBe(true);
|
|
62
|
-
expect(canOpenActiveAgent({ status: "queued", session: undefined } as any)).toBe(false);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("FleetList bounded window", () => {
|
|
67
|
-
it("keeps every FleetView state within six total rows", () => {
|
|
68
|
-
for (const agentCount of [0, 1, 2, 4, 5, 12]) {
|
|
69
|
-
for (let selected = 0; selected <= agentCount; selected++) {
|
|
70
|
-
const window = calculateFleetAgentWindow(agentCount, selected);
|
|
71
|
-
const rows = 2 + (window.hiddenAbove > 0 ? 1 : 0)
|
|
72
|
-
+ (window.end - window.start)
|
|
73
|
-
+ (window.hiddenBelow > 0 ? 1 : 0);
|
|
74
|
-
expect(rows).toBeLessThanOrEqual(FLEET_MAX_RENDER_ROWS);
|
|
75
|
-
if (agentCount > 0 && selected > 0) {
|
|
76
|
-
expect(selected - 1).toBeGreaterThanOrEqual(window.start);
|
|
77
|
-
expect(selected - 1).toBeLessThan(window.end);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("reserves indicator rows while keeping the selected middle agent visible", () => {
|
|
84
|
-
const window = calculateFleetAgentWindow(12, 6);
|
|
85
|
-
expect(window.hiddenAbove).toBeGreaterThan(0);
|
|
86
|
-
expect(window.hiddenBelow).toBeGreaterThan(0);
|
|
87
|
-
expect(5).toBeGreaterThanOrEqual(window.start);
|
|
88
|
-
expect(5).toBeLessThan(window.end);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("slides the visible window while navigating the full roster", () => {
|
|
92
|
-
const { render, input } = createFleetHarness(makeAgents(12));
|
|
93
|
-
input("\u001b[B");
|
|
94
|
-
for (let i = 0; i < 6; i++) input("\u001b[B");
|
|
95
|
-
const middle = render();
|
|
96
|
-
expect(middle.length).toBeLessThanOrEqual(FLEET_MAX_RENDER_ROWS);
|
|
97
|
-
expect(middle.some(line => /↑ \d+ more/.test(line))).toBe(true);
|
|
98
|
-
expect(middle.some(line => /↓ \d+ more/.test(line))).toBe(true);
|
|
99
|
-
expect(middle.some(line => line.includes("●"))).toBe(true);
|
|
100
|
-
|
|
101
|
-
input("\u001b[A");
|
|
102
|
-
const afterUp = render();
|
|
103
|
-
expect(afterUp.length).toBeLessThanOrEqual(FLEET_MAX_RENDER_ROWS);
|
|
104
|
-
expect(afterUp.some(line => line.includes("●"))).toBe(true);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("keeps the first and last windows bounded", () => {
|
|
108
|
-
const { render, input } = createFleetHarness(makeAgents(12));
|
|
109
|
-
expect(render().length).toBeLessThanOrEqual(FLEET_MAX_RENDER_ROWS);
|
|
110
|
-
input("\u001b[B");
|
|
111
|
-
for (let i = 0; i < 12; i++) input("\u001b[B");
|
|
112
|
-
const last = render();
|
|
113
|
-
expect(last.length).toBeLessThanOrEqual(FLEET_MAX_RENDER_ROWS);
|
|
114
|
-
expect(last.some(line => /↓ \d+ more/.test(line))).toBe(false);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("skips unchanged timer updates but redraws once when the active roster changes", () => {
|
|
118
|
-
const { manager, fleet, tui } = createFleetHarness(makeAgents(5));
|
|
119
|
-
tui.requestRender.mockClear();
|
|
120
|
-
fleet.update();
|
|
121
|
-
expect(tui.requestRender).not.toHaveBeenCalled();
|
|
122
|
-
|
|
123
|
-
manager.records.pop();
|
|
124
|
-
fleet.update();
|
|
125
|
-
expect(tui.requestRender).toHaveBeenCalledWith(true);
|
|
126
|
-
fleet.dispose();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("clears the widget when the active roster becomes empty", () => {
|
|
130
|
-
const { manager, fleet, ui } = createFleetHarness(makeAgents(1));
|
|
131
|
-
manager.records.splice(0);
|
|
132
|
-
fleet.update();
|
|
133
|
-
expect(ui.setWidget).toHaveBeenLastCalledWith("fleet", undefined);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import {
|
|
6
|
-
executeWriteWithMetadata,
|
|
7
|
-
MAX_DURABLE_DIFF_BYTES,
|
|
8
|
-
WriteExecutionMetadataStore,
|
|
9
|
-
} from "../src/write-execution.js";
|
|
10
|
-
|
|
11
|
-
const tempDirs: string[] = [];
|
|
12
|
-
|
|
13
|
-
afterEach(async () => {
|
|
14
|
-
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
async function tempDir(): Promise<string> {
|
|
18
|
-
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-write-test-"));
|
|
19
|
-
tempDirs.push(dir);
|
|
20
|
-
return dir;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
describe("executeWriteWithMetadata", () => {
|
|
24
|
-
it("writes a new file and returns a durable display diff and patch", async () => {
|
|
25
|
-
const cwd = await tempDir();
|
|
26
|
-
const store = new WriteExecutionMetadataStore();
|
|
27
|
-
const result = await executeWriteWithMetadata(store, "call-new", {
|
|
28
|
-
path: "nested/file.txt",
|
|
29
|
-
content: "new\n",
|
|
30
|
-
}, undefined, cwd);
|
|
31
|
-
|
|
32
|
-
expect(await readFile(join(cwd, "nested/file.txt"), "utf8")).toBe("new\n");
|
|
33
|
-
expect(result.content[0]?.text).toContain("Successfully wrote to nested/file.txt");
|
|
34
|
-
expect(result.details?.diff).toContain("+1 new");
|
|
35
|
-
expect(result.details?.patch).toContain("+new");
|
|
36
|
-
expect(store.get("call-new")?.diff).toBe(result.details?.diff);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("returns durable details that remain independent of later file changes", async () => {
|
|
40
|
-
const cwd = await tempDir();
|
|
41
|
-
await writeFile(join(cwd, "file.txt"), "old\n", "utf8");
|
|
42
|
-
const store = new WriteExecutionMetadataStore();
|
|
43
|
-
const result = await executeWriteWithMetadata(store, "call-existing", {
|
|
44
|
-
path: "file.txt",
|
|
45
|
-
content: "new\n",
|
|
46
|
-
}, undefined, cwd);
|
|
47
|
-
const persisted = { ...result.details };
|
|
48
|
-
|
|
49
|
-
await writeFile(join(cwd, "file.txt"), "later\n", "utf8");
|
|
50
|
-
|
|
51
|
-
expect(persisted.diff).toContain("-1 old");
|
|
52
|
-
expect(persisted.diff).toContain("+1 new");
|
|
53
|
-
expect(persisted.patch).toContain("-old");
|
|
54
|
-
expect(persisted.patch).toContain("+new");
|
|
55
|
-
expect(persisted).not.toHaveProperty("previousContent");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("writes successfully but marks an oversized durable diff unavailable", async () => {
|
|
59
|
-
const cwd = await tempDir();
|
|
60
|
-
const store = new WriteExecutionMetadataStore();
|
|
61
|
-
const content = "x".repeat(MAX_DURABLE_DIFF_BYTES + 1);
|
|
62
|
-
const result = await executeWriteWithMetadata(store, "call-large", {
|
|
63
|
-
path: "large.txt",
|
|
64
|
-
content,
|
|
65
|
-
}, undefined, cwd);
|
|
66
|
-
|
|
67
|
-
expect((await readFile(join(cwd, "large.txt"), "utf8")).length).toBe(content.length);
|
|
68
|
-
expect(result.details?.diffUnavailableReason).toContain("durable diff exceeds");
|
|
69
|
-
expect(result.details).not.toHaveProperty("diff");
|
|
70
|
-
expect(store.get("call-large")?.previousContent).toBe("");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("does not fabricate metadata when the write fails", async () => {
|
|
74
|
-
const cwd = await tempDir();
|
|
75
|
-
const store = new WriteExecutionMetadataStore();
|
|
76
|
-
|
|
77
|
-
await expect(executeWriteWithMetadata(store, "call-fail", {
|
|
78
|
-
path: "/dev/null/pi-subagents-impossible/file.txt",
|
|
79
|
-
content: "nope",
|
|
80
|
-
}, undefined, cwd)).rejects.toBeTruthy();
|
|
81
|
-
expect(store.get("call-fail")).toBeUndefined();
|
|
82
|
-
});
|
|
83
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "Bundler",
|
|
6
|
-
"strict": true,
|
|
7
|
-
"declaration": true,
|
|
8
|
-
"declarationMap": true,
|
|
9
|
-
"sourceMap": true,
|
|
10
|
-
"rootDir": "src",
|
|
11
|
-
"outDir": "dist",
|
|
12
|
-
"skipLibCheck": true,
|
|
13
|
-
"noEmitOnError": true
|
|
14
|
-
},
|
|
15
|
-
"include": ["src/**/*.ts"]
|
|
16
|
-
}
|