@esso0428/pi-subagents 0.15.14 → 0.15.16
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 +9 -32
- package/dist/ui/agent-widget.d.ts +8 -0
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +40 -12
- package/dist/ui/agent-widget.js.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.js +2 -4
- package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.js +4 -10
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-result.d.ts +9 -16
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-result.js +71 -121
- package/dist/ui/ccstyle/tool-result.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +14 -9
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +60 -72
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +4 -1
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +95 -94
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/dist/write-execution.d.ts +0 -2
- package/dist/write-execution.d.ts.map +1 -1
- package/dist/write-execution.js +1 -5
- package/dist/write-execution.js.map +1 -1
- package/package.json +1 -1
- package/src/ui/agent-widget.ts +106 -20
- package/src/ui/ccstyle/diff/diff-renderer.ts +2 -7
- package/src/ui/ccstyle/tool-renderer.ts +9 -22
- package/src/ui/ccstyle/tool-result.ts +82 -186
- package/src/ui/conversation-blocks.ts +13 -11
- package/src/ui/conversation-timeline.ts +211 -122
- package/src/ui/conversation-viewer.ts +149 -125
- package/src/ui/fleet-list.ts +34 -1
- package/src/write-execution.ts +1 -7
- package/test/ui/agent-widget.test.ts +161 -0
- package/test/ui/conversation-diff.test.ts +1 -47
- package/test/ui/conversation-viewer.test.ts +181 -112
- package/test/ui/fleet-list.test.ts +5 -1
- package/test/write-execution.test.ts +0 -2
- package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +0 -53
- package/.pi/workflows/index.json +0 -5
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AgentManager } from "../../src/agent-manager.js";
|
|
3
|
+
import {
|
|
4
|
+
AgentWidget,
|
|
5
|
+
getWidgetLineBudget,
|
|
6
|
+
MAX_WIDGET_LINES,
|
|
7
|
+
type AgentActivity,
|
|
8
|
+
type Theme,
|
|
9
|
+
} from "../../src/ui/agent-widget.js";
|
|
10
|
+
import type { AgentRecord } from "../../src/types.js";
|
|
11
|
+
|
|
12
|
+
const theme: Theme = {
|
|
13
|
+
fg: (_color, text) => text,
|
|
14
|
+
bold: (text) => text,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const managers: AgentManager[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const manager of managers.splice(0)) manager.dispose();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function makeRecord(overrides: Partial<AgentRecord> = {}): AgentRecord {
|
|
24
|
+
return {
|
|
25
|
+
id: "agent-1",
|
|
26
|
+
type: "Explore",
|
|
27
|
+
description: "Inspect the repository",
|
|
28
|
+
status: "completed",
|
|
29
|
+
toolUses: 0,
|
|
30
|
+
startedAt: 1_000,
|
|
31
|
+
completedAt: 2_000,
|
|
32
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
33
|
+
compactionCount: 0,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderWidget(
|
|
39
|
+
records: AgentRecord[],
|
|
40
|
+
rows = 24,
|
|
41
|
+
activity = new Map<string, AgentActivity>(),
|
|
42
|
+
mode: "all" | "background" | "off" = "all",
|
|
43
|
+
) {
|
|
44
|
+
const manager = { listAgents: () => records } as unknown as AgentManager;
|
|
45
|
+
const widget = new AgentWidget(manager, activity, () => mode);
|
|
46
|
+
const tui = { terminal: { columns: 200, rows } };
|
|
47
|
+
const render = () => (widget as any).renderWidget(tui, theme) as string[];
|
|
48
|
+
return { render, widget };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createWidgetHarness(records: AgentRecord[]) {
|
|
52
|
+
const manager = { listAgents: () => records } as unknown as AgentManager;
|
|
53
|
+
let factory: ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }) | undefined;
|
|
54
|
+
const tui = {
|
|
55
|
+
terminal: { columns: 120, rows: 24 },
|
|
56
|
+
requestRender: vi.fn(),
|
|
57
|
+
};
|
|
58
|
+
const ui = {
|
|
59
|
+
setStatus: vi.fn(),
|
|
60
|
+
setWidget: vi.fn((_key: string, content: any) => { factory = content; }),
|
|
61
|
+
};
|
|
62
|
+
const widget = new AgentWidget(manager, new Map(), () => "all");
|
|
63
|
+
widget.setUICtx(ui);
|
|
64
|
+
widget.update();
|
|
65
|
+
factory?.(tui, theme).render();
|
|
66
|
+
tui.requestRender.mockClear();
|
|
67
|
+
return { records, tui, ui, widget, render: () => factory?.(tui, theme).render() ?? [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("AgentWidget live records", () => {
|
|
71
|
+
it("keeps restored history in the live widget and manager history", () => {
|
|
72
|
+
const manager = new AgentManager();
|
|
73
|
+
managers.push(manager);
|
|
74
|
+
manager.restoreCompleted([makeRecord({ id: "history", description: "restored history" })]);
|
|
75
|
+
|
|
76
|
+
const widget = new AgentWidget(manager, new Map(), () => "all");
|
|
77
|
+
const tui = { terminal: { columns: 120, rows: 24 } };
|
|
78
|
+
|
|
79
|
+
expect((widget as any).renderWidget(tui, theme).join("\n")).toContain("restored history");
|
|
80
|
+
expect(manager.listAgents().map(record => record.id)).toEqual(["history"]);
|
|
81
|
+
expect(manager.getRecord("history")).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps a live completed record visible after markFinished", () => {
|
|
85
|
+
const record = makeRecord({ id: "live", description: "live completion" });
|
|
86
|
+
const { render, widget } = renderWidget([record]);
|
|
87
|
+
|
|
88
|
+
widget.markFinished(record.id);
|
|
89
|
+
|
|
90
|
+
expect(render().join("\n")).toContain("live completion");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("normalizes multiline rows and bounds output by the responsive ceiling", () => {
|
|
94
|
+
const running = makeRecord({
|
|
95
|
+
id: "running",
|
|
96
|
+
status: "running",
|
|
97
|
+
completedAt: undefined,
|
|
98
|
+
description: "running\nsecond line",
|
|
99
|
+
});
|
|
100
|
+
const finished = makeRecord({
|
|
101
|
+
id: "finished",
|
|
102
|
+
description: "finished\nsecond line",
|
|
103
|
+
});
|
|
104
|
+
const extraFinished = Array.from({ length: 10 }, (_, i) => makeRecord({
|
|
105
|
+
id: `finished-${i}`,
|
|
106
|
+
description: `extra ${i}`,
|
|
107
|
+
}));
|
|
108
|
+
const activity = new Map<string, AgentActivity>([
|
|
109
|
+
[running.id, {
|
|
110
|
+
activeTools: new Map(),
|
|
111
|
+
toolUses: 0,
|
|
112
|
+
responseText: "activity\nsecond line",
|
|
113
|
+
turnCount: 1,
|
|
114
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
115
|
+
}],
|
|
116
|
+
]);
|
|
117
|
+
const rows = 9;
|
|
118
|
+
const { render } = renderWidget([running, finished, ...extraFinished], rows, activity);
|
|
119
|
+
const lines = render();
|
|
120
|
+
|
|
121
|
+
expect(lines.every(line => !/[\r\n]/.test(line))).toBe(true);
|
|
122
|
+
expect(lines.length).toBeLessThanOrEqual(getWidgetLineBudget(rows));
|
|
123
|
+
expect(lines.length).toBeLessThanOrEqual(MAX_WIDGET_LINES);
|
|
124
|
+
expect(lines.join("\n")).toContain("running second line");
|
|
125
|
+
expect(lines.some(line => line.includes("more"))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("counts queued records in the overflow indicator on short terminals", () => {
|
|
129
|
+
const running = makeRecord({ id: "running", status: "running", completedAt: undefined });
|
|
130
|
+
const queued = makeRecord({ id: "queued", status: "queued", completedAt: undefined });
|
|
131
|
+
const { render } = renderWidget([running, queued], 6);
|
|
132
|
+
|
|
133
|
+
const output = render().join("\\n");
|
|
134
|
+
expect(output).toContain("+2 more");
|
|
135
|
+
expect(output).toContain("1 queued");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("reserves editor space on short terminals while retaining the widget ceiling", () => {
|
|
139
|
+
expect(getWidgetLineBudget(9)).toBe(5);
|
|
140
|
+
expect(getWidgetLineBudget(100)).toBe(MAX_WIDGET_LINES);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("does not request a render when a live snapshot is unchanged", () => {
|
|
144
|
+
vi.useFakeTimers();
|
|
145
|
+
vi.setSystemTime(10_000);
|
|
146
|
+
try {
|
|
147
|
+
const running = makeRecord({ id: "running", status: "running", completedAt: undefined });
|
|
148
|
+
const { widget, tui } = createWidgetHarness([running]);
|
|
149
|
+
|
|
150
|
+
widget.update();
|
|
151
|
+
expect(tui.requestRender).not.toHaveBeenCalled();
|
|
152
|
+
|
|
153
|
+
running.description = "changed description";
|
|
154
|
+
widget.update();
|
|
155
|
+
expect(tui.requestRender).toHaveBeenCalledTimes(1);
|
|
156
|
+
widget.dispose();
|
|
157
|
+
} finally {
|
|
158
|
+
vi.useRealTimers();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
renderEditDiffResult,
|
|
5
5
|
renderWriteDiffResult,
|
|
6
6
|
} from "../../src/ui/ccstyle/diff/diff-renderer.js";
|
|
7
|
-
import {
|
|
7
|
+
import { renderRichToolResult } from "../../src/ui/ccstyle/tool-result.js";
|
|
8
8
|
import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../../src/ui/ccstyle/diff/types.js";
|
|
9
9
|
|
|
10
10
|
const theme = {
|
|
@@ -66,52 +66,6 @@ describe("viewer-local ccstyle diff renderer", () => {
|
|
|
66
66
|
expect(rendered).not.toContain("later");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it("matches the pi-cc Input/Output tree style and hover affordance", () => {
|
|
70
|
-
const component = createViewerCcstyleResult("read", {
|
|
71
|
-
content: [{ type: "text", text: "one\ntwo\nthree" }],
|
|
72
|
-
}, { expanded: true }, theme, {
|
|
73
|
-
args: { path: "file.txt", offset: 2 },
|
|
74
|
-
diffConfig: { expandedPreviewMaxLines: 2 },
|
|
75
|
-
}) as any;
|
|
76
|
-
|
|
77
|
-
const initial = component.render(100).join("\n");
|
|
78
|
-
expect(initial).toContain("<accent><bold>Input</bold></accent>");
|
|
79
|
-
expect(initial).toContain("<dim>path: </dim><muted>file.txt</muted>");
|
|
80
|
-
expect(initial).toContain("<accent><bold>Output</bold></accent>");
|
|
81
|
-
expect(initial).toContain("click to show more");
|
|
82
|
-
const outputHintLine = component.showMoreLine("output");
|
|
83
|
-
expect(outputHintLine).toEqual(expect.any(Number));
|
|
84
|
-
component.setHoveredSection("output");
|
|
85
|
-
expect(component.render(100).join("\n")).toContain("<text> click to show more</text>");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("reads tool-hint hover state at render time", () => {
|
|
89
|
-
let hovered = false;
|
|
90
|
-
const component = createViewerCcstyleResult("read", {
|
|
91
|
-
content: [{ type: "text", text: "one\ntwo" }],
|
|
92
|
-
}, { expanded: false, isHovered: () => hovered }, theme, {
|
|
93
|
-
args: { path: "file.txt" },
|
|
94
|
-
isHovered: () => hovered,
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
expect(component?.render(100).join("\n")).not.toContain("<text>click to show more</text>");
|
|
98
|
-
hovered = true;
|
|
99
|
-
expect(component?.render(100).join("\n")).toContain("<text>click to show more</text>");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("labels persisted overwrites correctly without reading the current file", () => {
|
|
103
|
-
const component = renderRichToolResult("write", {
|
|
104
|
-
content: [{ type: "text", text: "Successfully wrote" }],
|
|
105
|
-
details: {
|
|
106
|
-
diff: "-1 old\n+1 new",
|
|
107
|
-
patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
|
|
108
|
-
},
|
|
109
|
-
}, { expanded: false }, theme, { args: { path: "file.txt", content: "new\n" } });
|
|
110
|
-
|
|
111
|
-
expect(component?.render(100).join("\\n")).toContain("overwritten");
|
|
112
|
-
expect(component?.render(100).join("\\n")).toContain("click to show more");
|
|
113
|
-
});
|
|
114
|
-
|
|
115
69
|
it("returns an explicit unavailable row instead of an empty success card", () => {
|
|
116
70
|
const component = renderRichToolResult("write", {
|
|
117
71
|
content: [{ type: "text", text: "Successfully wrote" }],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
2
3
|
import { ConversationViewer, createStaticConversationSource } from "../../src/ui/conversation-viewer.js";
|
|
3
4
|
|
|
4
5
|
const theme = {
|
|
@@ -28,6 +29,28 @@ function tui() {
|
|
|
28
29
|
} as any;
|
|
29
30
|
}
|
|
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
|
+
|
|
31
54
|
describe("ConversationViewer", () => {
|
|
32
55
|
it("renders assistant fenced code through the Markdown renderer", () => {
|
|
33
56
|
const ui = tui();
|
|
@@ -85,8 +108,6 @@ describe("ConversationViewer", () => {
|
|
|
85
108
|
expect(liveFooter).toContain("j/k scroll · J/K messages · [/] tools · w preview · g/G t M o O /n N q");
|
|
86
109
|
expect(liveFooter).toContain("e steer");
|
|
87
110
|
expect(liveFooter).toContain("x stop");
|
|
88
|
-
live.handleInput("\r");
|
|
89
|
-
expect(live.render(120).join("\n")).not.toContain("Enter send");
|
|
90
111
|
live.handleInput("e");
|
|
91
112
|
expect(live.render(120).join("\n")).toContain("Enter send");
|
|
92
113
|
live.handleInput("\x1b");
|
|
@@ -152,78 +173,62 @@ describe("ConversationViewer", () => {
|
|
|
152
173
|
vi.fn(),
|
|
153
174
|
);
|
|
154
175
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
expect(initial).not.toContain("READ");
|
|
176
|
+
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
177
|
+
viewer.handleInput("\t");
|
|
158
178
|
viewer.handleInput("\r");
|
|
159
179
|
expect(viewer.render(120).join("\n")).toContain("six");
|
|
160
180
|
});
|
|
161
181
|
|
|
162
|
-
it("
|
|
182
|
+
it("toggles mouse expansion only on the normalized click event", () => {
|
|
163
183
|
const viewer = new ConversationViewer(
|
|
164
184
|
tui(),
|
|
165
185
|
createStaticConversationSource([
|
|
166
|
-
{ role: "assistant", content: [{ type: "
|
|
167
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "
|
|
168
|
-
{ role: "assistant", content: [{ type: "text", text: "second" }] },
|
|
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" }] },
|
|
169
188
|
] as any),
|
|
170
189
|
record(),
|
|
171
190
|
undefined,
|
|
172
191
|
theme,
|
|
173
192
|
vi.fn(),
|
|
174
193
|
);
|
|
175
|
-
viewer.render(120);
|
|
176
|
-
viewer.
|
|
177
|
-
expect(
|
|
178
|
-
|
|
179
|
-
viewer.
|
|
180
|
-
expect(
|
|
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");
|
|
181
202
|
});
|
|
182
203
|
|
|
183
|
-
it("focuses
|
|
204
|
+
it("focuses USER and ASSISTANT blocks from their rendered spans", () => {
|
|
184
205
|
const viewer = new ConversationViewer(
|
|
185
206
|
tui(),
|
|
186
207
|
createStaticConversationSource([
|
|
187
|
-
{ role: "
|
|
188
|
-
{ role: "
|
|
189
|
-
{ role: "assistant", content: [{ type: "text", text: "between" }, { type: "toolCall", name: "read", arguments: { path: "second" } }] },
|
|
190
|
-
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "second output" }] },
|
|
208
|
+
{ role: "user", content: "user text" },
|
|
209
|
+
{ role: "assistant", content: [{ type: "text", text: "assistant text" }] },
|
|
191
210
|
] as any),
|
|
192
211
|
record(),
|
|
193
212
|
undefined,
|
|
194
213
|
theme,
|
|
195
214
|
vi.fn(),
|
|
196
215
|
);
|
|
197
|
-
viewer.render(120);
|
|
198
|
-
const timeline = (viewer as any).timeline;
|
|
199
|
-
const ids = timeline.getSnapshot().interactiveToolIds;
|
|
200
|
-
expect(ids).toHaveLength(2);
|
|
201
216
|
|
|
202
|
-
viewer.handleInput("]");
|
|
203
|
-
expect(timeline.getFocusedToolCallId()).toBe(ids[0]);
|
|
204
217
|
viewer.render(120);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
expect(
|
|
208
|
-
viewer.
|
|
209
|
-
expect((viewer as any).
|
|
210
|
-
|
|
211
|
-
|
|
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();
|
|
212
227
|
});
|
|
213
228
|
|
|
214
|
-
it("
|
|
215
|
-
const ui = tui();
|
|
216
|
-
const custom = vi.fn((factory: any) => {
|
|
217
|
-
const preview = factory(ui, theme, undefined, vi.fn());
|
|
218
|
-
const rendered = preview.render(100).join("\\n");
|
|
219
|
-
expect(rendered).toContain("Input");
|
|
220
|
-
expect(rendered).toContain("Output");
|
|
221
|
-
expect(rendered).toContain("one");
|
|
222
|
-
expect(rendered).toContain("six");
|
|
223
|
-
return Promise.resolve(undefined);
|
|
224
|
-
});
|
|
229
|
+
it("focuses normal tool rows without expanding, while hint clicks expand", () => {
|
|
225
230
|
const viewer = new ConversationViewer(
|
|
226
|
-
|
|
231
|
+
tui(),
|
|
227
232
|
createStaticConversationSource([
|
|
228
233
|
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
|
|
229
234
|
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
|
|
@@ -232,81 +237,92 @@ describe("ConversationViewer", () => {
|
|
|
232
237
|
undefined,
|
|
233
238
|
theme,
|
|
234
239
|
vi.fn(),
|
|
235
|
-
undefined,
|
|
236
|
-
undefined,
|
|
237
|
-
undefined,
|
|
238
|
-
{ pi: {} as any, ctx: { ui: { custom } } as any, readOnly: true },
|
|
239
240
|
);
|
|
240
|
-
|
|
241
|
-
viewer.
|
|
242
|
-
viewer.
|
|
243
|
-
|
|
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");
|
|
244
263
|
});
|
|
245
264
|
|
|
246
|
-
it("
|
|
247
|
-
const railTheme = {
|
|
248
|
-
fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
|
|
249
|
-
bold: (text: string) => `<bold>${text}</bold>`,
|
|
250
|
-
};
|
|
265
|
+
it("synchronizes J/K message focus and anchors [/] around the selected message", () => {
|
|
251
266
|
const viewer = new ConversationViewer(
|
|
252
267
|
tui(),
|
|
253
268
|
createStaticConversationSource([
|
|
254
|
-
{ role: "
|
|
255
|
-
{ role: "
|
|
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" }] },
|
|
256
276
|
] as any),
|
|
257
277
|
record(),
|
|
258
278
|
undefined,
|
|
259
|
-
railTheme,
|
|
260
|
-
vi.fn(),
|
|
261
|
-
);
|
|
262
|
-
expect(viewer.render(120).join("\\n")).toContain("<toolTitle>▌</toolTitle>");
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it("scrolls the transcript with a normalized mouse wheel event", () => {
|
|
266
|
-
const longText = Array.from({ length: 80 }, (_, index) => `line-${index}`).join("\n");
|
|
267
|
-
const viewer = new ConversationViewer(
|
|
268
|
-
tui(),
|
|
269
|
-
createStaticConversationSource([{ role: "assistant", content: [{ type: "text", text: longText }] }] as any),
|
|
270
|
-
record(),
|
|
271
|
-
undefined,
|
|
272
279
|
theme,
|
|
273
280
|
vi.fn(),
|
|
274
281
|
);
|
|
282
|
+
|
|
275
283
|
viewer.render(120);
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
expect((viewer as any).
|
|
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");
|
|
280
303
|
});
|
|
281
304
|
|
|
282
|
-
it("
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
);
|
|
294
|
-
expect(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
expect(
|
|
300
|
-
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
301
|
-
viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 1 } as any);
|
|
302
|
-
expect(viewer.render(120).join("\n")).toContain("six");
|
|
303
|
-
viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 1 } as any);
|
|
304
|
-
expect(viewer.render(120).join("\n")).toContain("six");
|
|
305
|
-
viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 2 } as any);
|
|
306
|
-
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
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");
|
|
307
323
|
});
|
|
308
324
|
|
|
309
|
-
it("highlights the tool hint
|
|
325
|
+
it("highlights the collapsed tool hint and the viewer close control independently", () => {
|
|
310
326
|
const semanticTheme = {
|
|
311
327
|
fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
|
|
312
328
|
bold: (text: string) => `<bold>${text}</bold>`,
|
|
@@ -325,21 +341,74 @@ describe("ConversationViewer", () => {
|
|
|
325
341
|
);
|
|
326
342
|
const initial = viewer.render(120).join("\n");
|
|
327
343
|
const span = (viewer as any).timeline.getSnapshot().toolSpans[0];
|
|
328
|
-
|
|
329
|
-
const hintY = 4 + span.clickStartLine;
|
|
344
|
+
const hintY = viewerY(span.clickStartLine);
|
|
330
345
|
expect(initial).not.toContain("<text>click to show more</text>");
|
|
331
|
-
viewer.handleMouse(
|
|
346
|
+
viewer.handleMouse(mouse("move", hintY, { button: "none" }));
|
|
332
347
|
expect(viewer.render(120).join("\n")).toContain("<text>click to show more</text>");
|
|
333
|
-
|
|
334
|
-
expect(viewer.render(120).join("\n")).toContain("[esc]");
|
|
335
|
-
viewer.handleMouse({ type: "move", button: "none", x: 114, y: 1, screenX: 114, screenY: 1, width: 120, height: 30, shift: false, alt: false, ctrl: false } as any);
|
|
348
|
+
viewer.handleMouse(mouse("move", 1, { button: "none", x: 114, screenX: 114, screenY: 1 }));
|
|
336
349
|
expect(viewer.render(120).join("\n")).toContain("<text><bold>[esc]</bold></text>");
|
|
337
|
-
|
|
338
|
-
expect(viewer.handleMouse({
|
|
339
|
-
expect(viewer.handleMouse({ ...close, type: "click", clickCount: 1 } as any)?.handled).toBe(true);
|
|
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);
|
|
340
352
|
expect(done).toHaveBeenCalledTimes(1);
|
|
341
353
|
});
|
|
342
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
|
+
|
|
343
412
|
it("keeps historical viewers read-only and closes on Escape", () => {
|
|
344
413
|
const ui = tui();
|
|
345
414
|
const steer = vi.fn();
|
|
@@ -114,12 +114,16 @@ describe("FleetList bounded window", () => {
|
|
|
114
114
|
expect(last.some(line => /↓ \d+ more/.test(line))).toBe(false);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
it("
|
|
117
|
+
it("skips unchanged timer updates but redraws once when the active roster changes", () => {
|
|
118
118
|
const { manager, fleet, tui } = createFleetHarness(makeAgents(5));
|
|
119
119
|
tui.requestRender.mockClear();
|
|
120
|
+
fleet.update();
|
|
121
|
+
expect(tui.requestRender).not.toHaveBeenCalled();
|
|
122
|
+
|
|
120
123
|
manager.records.pop();
|
|
121
124
|
fleet.update();
|
|
122
125
|
expect(tui.requestRender).toHaveBeenCalledWith(true);
|
|
126
|
+
fleet.dispose();
|
|
123
127
|
});
|
|
124
128
|
|
|
125
129
|
it("clears the widget when the active roster becomes empty", () => {
|
|
@@ -31,7 +31,6 @@ describe("executeWriteWithMetadata", () => {
|
|
|
31
31
|
|
|
32
32
|
expect(await readFile(join(cwd, "nested/file.txt"), "utf8")).toBe("new\n");
|
|
33
33
|
expect(result.content[0]?.text).toContain("Successfully wrote to nested/file.txt");
|
|
34
|
-
expect(result.details?.fileExistedBeforeWrite).toBe(false);
|
|
35
34
|
expect(result.details?.diff).toContain("+1 new");
|
|
36
35
|
expect(result.details?.patch).toContain("+new");
|
|
37
36
|
expect(store.get("call-new")?.diff).toBe(result.details?.diff);
|
|
@@ -49,7 +48,6 @@ describe("executeWriteWithMetadata", () => {
|
|
|
49
48
|
|
|
50
49
|
await writeFile(join(cwd, "file.txt"), "later\n", "utf8");
|
|
51
50
|
|
|
52
|
-
expect(persisted.fileExistedBeforeWrite).toBe(true);
|
|
53
51
|
expect(persisted.diff).toContain("-1 old");
|
|
54
52
|
expect(persisted.diff).toContain("+1 new");
|
|
55
53
|
expect(persisted.patch).toContain("-old");
|