@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +9 -32
  2. package/dist/ui/agent-widget.d.ts +8 -0
  3. package/dist/ui/agent-widget.d.ts.map +1 -1
  4. package/dist/ui/agent-widget.js +40 -12
  5. package/dist/ui/agent-widget.js.map +1 -1
  6. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  7. package/dist/ui/ccstyle/diff/diff-renderer.js +2 -4
  8. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  9. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  10. package/dist/ui/ccstyle/tool-renderer.js +4 -10
  11. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  12. package/dist/ui/ccstyle/tool-result.d.ts +9 -16
  13. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  14. package/dist/ui/ccstyle/tool-result.js +71 -121
  15. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  16. package/dist/ui/conversation-timeline.d.ts +14 -9
  17. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  18. package/dist/ui/conversation-timeline.js +60 -72
  19. package/dist/ui/conversation-timeline.js.map +1 -1
  20. package/dist/ui/conversation-viewer.d.ts +4 -1
  21. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  22. package/dist/ui/conversation-viewer.js +95 -94
  23. package/dist/ui/conversation-viewer.js.map +1 -1
  24. package/dist/write-execution.d.ts +0 -2
  25. package/dist/write-execution.d.ts.map +1 -1
  26. package/dist/write-execution.js +1 -5
  27. package/dist/write-execution.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/ui/agent-widget.ts +106 -20
  30. package/src/ui/ccstyle/diff/diff-renderer.ts +2 -7
  31. package/src/ui/ccstyle/tool-renderer.ts +9 -22
  32. package/src/ui/ccstyle/tool-result.ts +82 -186
  33. package/src/ui/conversation-blocks.ts +13 -11
  34. package/src/ui/conversation-timeline.ts +211 -122
  35. package/src/ui/conversation-viewer.ts +149 -125
  36. package/src/ui/fleet-list.ts +34 -1
  37. package/src/write-execution.ts +1 -7
  38. package/test/ui/agent-widget.test.ts +161 -0
  39. package/test/ui/conversation-diff.test.ts +1 -47
  40. package/test/ui/conversation-viewer.test.ts +181 -112
  41. package/test/ui/fleet-list.test.ts +5 -1
  42. package/test/write-execution.test.ts +0 -2
  43. package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +0 -53
  44. 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 { createViewerCcstyleResult, renderRichToolResult } from "../../src/ui/ccstyle/tool-result.js";
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
- const initial = viewer.render(120).join("\n");
156
- expect(initial).not.toContain("six");
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("keeps J/K on user and assistant blocks while j/k remain line scrolling", () => {
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: "text", text: "first" }, { type: "toolCall", name: "read", arguments: { path: "x" } }] },
167
- { role: "toolResult", toolName: "read", content: [{ type: "text", text: "tool output" }] },
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.handleInput("J");
177
- expect((viewer as any).currentBlock().role).toBe("assistant");
178
- expect((viewer as any).currentBlock().markdown).toBe("second");
179
- viewer.handleInput("K");
180
- expect((viewer as any).currentBlock().markdown).toBe("first");
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 nearby tools with [ and ] without changing line-scroll keys", () => {
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: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "first" } }] },
188
- { role: "toolResult", toolName: "read", content: [{ type: "text", text: "first output" }] },
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
- expect((viewer as any).currentBlock().toolCallId).toBe(ids[0]);
206
- viewer.handleInput("]");
207
- expect(timeline.getFocusedToolCallId()).toBe(ids[1]);
208
- viewer.render(120);
209
- expect((viewer as any).currentBlock().toolCallId).toBe(ids[1]);
210
- viewer.handleInput("[");
211
- expect(timeline.getFocusedToolCallId()).toBe(ids[0]);
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("opens a complete read-only tool preview with w for the focused tool", () => {
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
- ui,
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
- viewer.render(120);
241
- viewer.handleInput("]");
242
- viewer.handleInput("w");
243
- expect(custom).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ overlay: true }));
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("uses a distinct focus rail for tool cards", () => {
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: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
255
- { role: "toolResult", toolName: "read", content: [{ type: "text", text: "done" }] },
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 before = (viewer as any).scrollOffset;
277
- const event = { type: "wheel", button: "none", x: 2, y: 5, screenX: 2, screenY: 5, width: 120, height: 30, shift: false, alt: false, ctrl: false, wheelDelta: -3 };
278
- expect(viewer.handleMouse(event as any)?.handled).toBe(true);
279
- expect((viewer as any).scrollOffset).toBeLessThan(before);
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("expands on the first click and collapses only on the second click", () => {
283
- const viewer = new ConversationViewer(
284
- tui(),
285
- createStaticConversationSource([
286
- { role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
287
- { role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
288
- ] as any),
289
- record(),
290
- undefined,
291
- theme,
292
- vi.fn(),
293
- );
294
- expect(viewer.render(120).join("\n")).not.toContain("six");
295
- const span = (viewer as any).timeline.getSnapshot().toolSpans[0];
296
- expect(span.clickStartLine).toEqual(expect.any(Number));
297
- const hintY = 4 + span.clickStartLine;
298
- const mouse = { button: "left", x: 1, screenX: 1, screenY: hintY, width: 120, height: 30, shift: false, alt: false, ctrl: false };
299
- expect(viewer.handleMouse({ ...mouse, type: "press", y: hintY } as any)?.handled).toBe(true);
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 on hover and closes from the viewer [esc] button", () => {
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
- expect(span.clickStartLine).toEqual(expect.any(Number));
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({ type: "move", button: "none", x: 2, y: hintY, screenX: 2, screenY: hintY, width: 120, height: 30, shift: false, alt: false, ctrl: false } as any);
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
- const close = { button: "left", x: 114, screenX: 114, screenY: 1, y: 1, width: 120, height: 30, shift: false, alt: false, ctrl: false };
338
- expect(viewer.handleMouse({ ...close, type: "press" } as any)?.handled).toBe(true);
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("forces a full render once when the active roster changes", () => {
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");