@esso0428/pi-subagents 0.15.17 → 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.
Files changed (92) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/agent-history-list.d.ts.map +1 -1
  3. package/dist/agent-history-list.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +47 -6
  6. package/dist/index.js.map +1 -1
  7. package/dist/output-file.js +3 -3
  8. package/dist/output-file.js.map +1 -1
  9. package/dist/ui/agent-widget.d.ts +11 -4
  10. package/dist/ui/agent-widget.d.ts.map +1 -1
  11. package/dist/ui/agent-widget.js +85 -19
  12. package/dist/ui/agent-widget.js.map +1 -1
  13. package/dist/ui/ccstyle/diff/ansi-utils.js +2 -2
  14. package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -1
  15. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -1
  16. package/dist/ui/ccstyle/diff/diff-presentation.js +0 -1
  17. package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -1
  18. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +1 -1
  19. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  20. package/dist/ui/ccstyle/diff/diff-renderer.js +15 -10
  21. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  22. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -1
  23. package/dist/ui/ccstyle/diff/line-width-safety.js +1 -2
  24. package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -1
  25. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts +19 -0
  26. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts.map +1 -0
  27. package/dist/ui/ccstyle/diff/shiki-highlight.js +85 -0
  28. package/dist/ui/ccstyle/diff/shiki-highlight.js.map +1 -0
  29. package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
  30. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  31. package/dist/ui/ccstyle/tool-renderer.js +1 -1
  32. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  33. package/dist/ui/ccstyle/tool-result.d.ts +9 -1
  34. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  35. package/dist/ui/ccstyle/tool-result.js +137 -5
  36. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  37. package/dist/ui/conversation-blocks.d.ts.map +1 -1
  38. package/dist/ui/conversation-blocks.js +14 -13
  39. package/dist/ui/conversation-blocks.js.map +1 -1
  40. package/dist/ui/conversation-role.d.ts +1 -1
  41. package/dist/ui/conversation-role.d.ts.map +1 -1
  42. package/dist/ui/conversation-search.js +4 -4
  43. package/dist/ui/conversation-search.js.map +1 -1
  44. package/dist/ui/conversation-timeline.d.ts +17 -1
  45. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  46. package/dist/ui/conversation-timeline.js +116 -43
  47. package/dist/ui/conversation-timeline.js.map +1 -1
  48. package/dist/ui/conversation-viewer.d.ts +5 -1
  49. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  50. package/dist/ui/conversation-viewer.js +67 -26
  51. package/dist/ui/conversation-viewer.js.map +1 -1
  52. package/dist/ui/deferred-ui-refresh.d.ts +15 -0
  53. package/dist/ui/deferred-ui-refresh.d.ts.map +1 -0
  54. package/dist/ui/deferred-ui-refresh.js +50 -0
  55. package/dist/ui/deferred-ui-refresh.js.map +1 -0
  56. package/dist/ui/fleet-list.d.ts +55 -14
  57. package/dist/ui/fleet-list.d.ts.map +1 -1
  58. package/dist/ui/fleet-list.js +178 -44
  59. package/dist/ui/fleet-list.js.map +1 -1
  60. package/dist/write-execution.d.ts.map +1 -1
  61. package/dist/write-execution.js +1 -1
  62. package/dist/write-execution.js.map +1 -1
  63. package/package.json +6 -5
  64. package/src/agent-history-list.ts +1 -1
  65. package/src/index.ts +3 -3
  66. package/src/output-file.ts +3 -3
  67. package/src/ui/ccstyle/diff/ansi-utils.ts +2 -2
  68. package/src/ui/ccstyle/diff/diff-presentation.ts +0 -1
  69. package/src/ui/ccstyle/diff/diff-renderer.ts +22 -21
  70. package/src/ui/ccstyle/diff/line-width-safety.ts +1 -2
  71. package/src/ui/ccstyle/diff/shiki-highlight.ts +97 -0
  72. package/src/ui/ccstyle/tool-renderer.ts +3 -3
  73. package/src/ui/ccstyle/tool-result.ts +175 -7
  74. package/src/ui/conversation-role.ts +1 -1
  75. package/src/ui/conversation-search.ts +4 -4
  76. package/src/ui/conversation-timeline.ts +7 -7
  77. package/src/ui/conversation-viewer.ts +16 -7
  78. package/src/ui/fleet-list.ts +3 -4
  79. package/src/write-execution.ts +2 -2
  80. package/test/agent-history-list.test.ts +0 -121
  81. package/test/agent-manager-history.test.ts +0 -88
  82. package/test/agent-menu-history-navigation.test.ts +0 -86
  83. package/test/output-file-history.test.ts +0 -50
  84. package/test/ui/agent-widget.test.ts +0 -172
  85. package/test/ui/conversation-blocks.test.ts +0 -103
  86. package/test/ui/conversation-diff.test.ts +0 -91
  87. package/test/ui/conversation-search.test.ts +0 -55
  88. package/test/ui/conversation-viewer.test.ts +0 -438
  89. package/test/ui/deferred-ui-refresh.test.ts +0 -55
  90. package/test/ui/fleet-list.test.ts +0 -389
  91. package/test/write-execution.test.ts +0 -83
  92. package/tsconfig.json +0 -16
@@ -1,121 +0,0 @@
1
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, describe, expect, it } from "vitest";
5
- import {
6
- buildAgentStatusMenuEntries,
7
- canOpenActiveAgent,
8
- canOpenAgentHistory,
9
- formatAgentHistoryOption,
10
- isTerminalAgentStatus,
11
- splitAgentRecords,
12
- } from "../src/agent-history-list.js";
13
-
14
- type TestRecord = {
15
- status: string;
16
- session?: object;
17
- transcriptPath?: string;
18
- type?: string;
19
- description?: string;
20
- startedAt?: number;
21
- completedAt?: number;
22
- };
23
-
24
- const tempDirectories: string[] = [];
25
-
26
- afterEach(() => {
27
- for (const directory of tempDirectories.splice(0)) {
28
- rmSync(directory, { recursive: true, force: true });
29
- }
30
- });
31
-
32
- function record(overrides: TestRecord): any {
33
- return {
34
- id: "agent-1",
35
- type: "Explore",
36
- description: "Inspect the repository",
37
- status: "completed",
38
- toolUses: 0,
39
- startedAt: 1_000,
40
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
41
- compactionCount: 0,
42
- ...overrides,
43
- };
44
- }
45
-
46
- describe("agent history list helpers", () => {
47
- it("recognizes exactly the five terminal statuses", () => {
48
- for (const status of ["completed", "steered", "stopped", "aborted", "error"]) {
49
- expect(isTerminalAgentStatus(status)).toBe(true);
50
- }
51
- for (const status of ["running", "queued", "unknown", ""]) {
52
- expect(isTerminalAgentStatus(status)).toBe(false);
53
- }
54
- });
55
-
56
- it("separates active and terminal records without mutating input", () => {
57
- const done = record({ status: "completed", session: {} });
58
- const running = record({ status: "running", session: {} });
59
- const stopped = record({ status: "stopped", session: {} });
60
- const queued = record({ status: "queued", session: {} });
61
- const input = [done, running, stopped, queued];
62
- const before = [...input];
63
-
64
- const result = splitAgentRecords(input, undefined);
65
-
66
- expect(result.active).toEqual([running, queued]);
67
- expect(result.history).toEqual([done, stopped]);
68
- expect(input).toEqual(before);
69
- expect(input).toEqual([done, running, stopped, queued]);
70
- });
71
-
72
- it("requires a session or existing transcript for history", () => {
73
- expect(canOpenAgentHistory(record({ session: undefined }), undefined)).toBe(false);
74
- expect(canOpenAgentHistory(record({ session: {} }), undefined)).toBe(true);
75
- expect(canOpenAgentHistory(record({ status: "running", session: {} }), undefined)).toBe(false);
76
- expect(canOpenActiveAgent(record({ status: "running", session: {} }))).toBe(true);
77
- expect(canOpenActiveAgent(record({ status: "queued", session: undefined }))).toBe(false);
78
- expect(canOpenActiveAgent(record({ status: "completed", session: {} }))).toBe(false);
79
- });
80
-
81
- it("opens history only for an existing project-local transcript", () => {
82
- const cwd = mkdtempSync(join(tmpdir(), "pi-subagents-history-list-"));
83
- tempDirectories.push(cwd);
84
- const transcriptPath = ".pi-subagents/agent-transcripts/agent.jsonl";
85
- mkdirSync(join(cwd, ".pi-subagents", "agent-transcripts"), { recursive: true });
86
- const historical = record({ session: undefined, transcriptPath });
87
-
88
- expect(canOpenAgentHistory(historical, cwd)).toBe(false);
89
- writeFileSync(join(cwd, transcriptPath), "{}\n");
90
- expect(canOpenAgentHistory(historical, cwd)).toBe(true);
91
- expect(canOpenAgentHistory(historical, undefined)).toBe(false);
92
- });
93
-
94
- it("builds separate menu entries with openable records only", () => {
95
- const entries = buildAgentStatusMenuEntries([
96
- record({ status: "running", session: {} }),
97
- record({ status: "queued", session: undefined }),
98
- record({ status: "completed", session: {} }),
99
- record({ status: "error", session: {} }),
100
- record({ status: "stopped", session: undefined }),
101
- ], undefined);
102
-
103
- expect(entries).toEqual(["Running agents (1)", "Agent history (2)"]);
104
- expect(buildAgentStatusMenuEntries([record({ status: "queued", session: undefined })], undefined)).toEqual([]);
105
- });
106
-
107
- it("formats a bounded history option using the display name and duration", () => {
108
- const option = formatAgentHistoryOption(record({
109
- status: "completed",
110
- type: "Explore",
111
- description: "A very long description ".repeat(20),
112
- startedAt: 1_000,
113
- completedAt: 3_000,
114
- }), 9_000);
115
-
116
- expect(option).toContain("[completed]");
117
- expect(option).toContain("Explore");
118
- expect(option).toContain("2.0s");
119
- expect(option.length).toBeLessThanOrEqual(140);
120
- });
121
- });
@@ -1,88 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
- import { AgentManager } from "../src/agent-manager.js";
3
-
4
- const managers: AgentManager[] = [];
5
-
6
- afterEach(() => {
7
- for (const manager of managers.splice(0)) manager.dispose();
8
- });
9
-
10
- function makeManager(): AgentManager {
11
- const manager = new AgentManager();
12
- managers.push(manager);
13
- return manager;
14
- }
15
-
16
- function record(overrides: Record<string, unknown> = {}) {
17
- return {
18
- id: "agent-1",
19
- type: "Explore",
20
- description: "Inspect the repository",
21
- status: "completed",
22
- result: "done",
23
- toolUses: 2,
24
- startedAt: 1_000,
25
- completedAt: 2_000,
26
- ...overrides,
27
- };
28
- }
29
-
30
- describe("AgentManager.restoreCompleted", () => {
31
- it("restores all five terminal statuses without sessions", () => {
32
- const manager = makeManager();
33
- manager.restoreCompleted(["completed", "steered", "stopped", "aborted", "error"].map((status, i) =>
34
- record({ id: `agent-${i}`, status }),
35
- ));
36
-
37
- expect(manager.listAgents()).toHaveLength(5);
38
- for (const restored of manager.listAgents()) {
39
- expect(["completed", "steered", "stopped", "aborted", "error"]).toContain(restored.status);
40
- expect(restored.session).toBeUndefined();
41
- expect(restored.abortController).toBeUndefined();
42
- expect(restored.promise).toBeUndefined();
43
- expect(restored.lifetimeUsage).toEqual({ input: 0, output: 0, cacheWrite: 0 });
44
- expect(restored.compactionCount).toBe(0);
45
- }
46
- });
47
-
48
- it("ignores running, queued, and invalid records", () => {
49
- const manager = makeManager();
50
- manager.restoreCompleted([
51
- record({ id: "running", status: "running" }),
52
- record({ id: "queued", status: "queued" }),
53
- { id: "invalid" },
54
- ]);
55
-
56
- expect(manager.listAgents()).toEqual([]);
57
- });
58
-
59
- it("keeps the last valid duplicate input", () => {
60
- const manager = makeManager();
61
- manager.restoreCompleted([
62
- record({ result: "first", startedAt: 1_000, completedAt: 1_100 }),
63
- record({ result: "last", startedAt: 2_000, completedAt: 2_100 }),
64
- ]);
65
-
66
- expect(manager.getRecord("agent-1")?.result).toBe("last");
67
- expect(manager.getRecord("agent-1")?.startedAt).toBe(2_000);
68
- });
69
-
70
- it("does not replace a live record and rejects unsafe persisted data", () => {
71
- const manager = makeManager();
72
- // Exercise the public map through a restored terminal, then make it live
73
- // without starting an LLM session.
74
- manager.restoreCompleted([record({ id: "live", result: "original" })]);
75
- const liveRecord = manager.getRecord("live")!;
76
- liveRecord.status = "running";
77
-
78
- manager.restoreCompleted([
79
- record({ id: "live", result: "replacement" }),
80
- record({ id: "bad-time", startedAt: Number.NaN }),
81
- record({ id: "bad-path", transcriptPath: "../../outside.jsonl" }),
82
- ]);
83
-
84
- expect(manager.getRecord("live")?.result).toBe("original");
85
- expect(manager.getRecord("bad-time")).toBeUndefined();
86
- expect(manager.getRecord("bad-path")).toBeUndefined();
87
- });
88
- });
@@ -1,86 +0,0 @@
1
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, describe, expect, it } from "vitest";
5
- import extension from "../src/index.js";
6
- import { agentHistoryLocator } from "../src/agent-history.js";
7
-
8
- const tempDirectories: string[] = [];
9
-
10
- afterEach(() => {
11
- for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
12
- });
13
-
14
- describe("/agents history navigation", () => {
15
- it("returns to the Agents menu after closing a history viewer", async () => {
16
- const cwd = mkdtempSync(join(tmpdir(), "pi-subagents-menu-test-"));
17
- tempDirectories.push(cwd);
18
- const transcript = ".pi-subagents/agent-transcripts/agent-1.jsonl";
19
- mkdirSync(join(cwd, ".pi-subagents/agent-transcripts"), { recursive: true });
20
- writeFileSync(join(cwd, transcript), JSON.stringify({ message: { role: "user", content: "hello" } }) + "\n");
21
-
22
- const eventHandlers = new Map<string, Set<(...args: any[]) => any>>();
23
- const events = {
24
- on: (name: string, handler: (...args: any[]) => any) => {
25
- const handlersForEvent = eventHandlers.get(name) ?? new Set<(...args: any[]) => any>();
26
- handlersForEvent.add(handler);
27
- eventHandlers.set(name, handlersForEvent);
28
- return () => handlersForEvent.delete(handler);
29
- },
30
- emit: (name: string, ...args: any[]) => {
31
- for (const handler of eventHandlers.get(name) ?? []) handler(...args);
32
- },
33
- };
34
- const handlers = new Map<string, (...args: any[]) => any>();
35
- let command: { handler: (_args: unknown, ctx: any) => Promise<void> } | undefined;
36
- const pi: any = {
37
- events,
38
- registerMessageRenderer: () => {},
39
- registerTool: () => {},
40
- registerCommand: (_name: string, definition: typeof command) => { command = definition; },
41
- on: (name: string, handler: (...args: any[]) => any) => { handlers.set(name, handler); },
42
- appendEntry: () => {},
43
- sendMessage: () => {},
44
- };
45
- extension(pi);
46
-
47
- const titles: string[] = [];
48
- let historySelections = 0;
49
- const ui = {
50
- notify: () => {},
51
- setWidget: () => {},
52
- setStatus: () => {},
53
- onTerminalInput: () => () => {},
54
- select: async (title: string, options: string[]) => {
55
- titles.push(title);
56
- if (title === "Agents" && titles.filter(item => item === "Agents").length === 1) {
57
- return options.find(option => option.startsWith("Agent history ("));
58
- }
59
- if (title === "Agent history" && historySelections++ === 0) return options[0];
60
- return undefined;
61
- },
62
- custom: async () => undefined,
63
- };
64
- const ctx = {
65
- cwd,
66
- ui,
67
- sessionManager: {
68
- getSessionId: () => undefined,
69
- getBranch: () => [{
70
- type: "custom",
71
- customType: "subagents:record",
72
- data: {
73
- id: "agent-1", type: "Explore", description: "Inspect", status: "completed",
74
- startedAt: 1, completedAt: 2, transcriptPath: agentHistoryLocator(cwd, join(cwd, transcript)),
75
- },
76
- }],
77
- },
78
- };
79
-
80
- await handlers.get("session_start")?.({}, ctx);
81
- await command?.handler({}, ctx);
82
- await handlers.get("session_shutdown")?.();
83
-
84
- expect(titles).toEqual(["Agents", "Agent history", "Agents"]);
85
- });
86
- });
@@ -1,50 +0,0 @@
1
- import { mkdtemp, readFile, rm } 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 { streamToOutputFile, writeInitialEntry } from "../src/output-file.js";
6
-
7
- const tempDirs: string[] = [];
8
-
9
- afterEach(async () => {
10
- await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
11
- });
12
-
13
- describe("streamToOutputFile durable message snapshots", () => {
14
- it("writes tool result details to both output and project history files", async () => {
15
- const dir = await mkdtemp(join(tmpdir(), "pi-subagents-output-test-"));
16
- tempDirs.push(dir);
17
- const outputPath = join(dir, "agent.output");
18
- const historyPath = join(dir, "history.jsonl");
19
- writeInitialEntry(outputPath, "agent-1", "run", dir);
20
- writeInitialEntry(historyPath, "agent-1", "run", dir);
21
-
22
- let listener: ((event: any) => void) | undefined;
23
- const session = {
24
- messages: [
25
- { role: "user", content: "run" },
26
- {
27
- role: "toolResult",
28
- toolCallId: "call-1",
29
- toolName: "write",
30
- content: [{ type: "text", text: "Successfully wrote to file.txt" }],
31
- details: { diff: "+1 new", patch: "+new" },
32
- },
33
- ],
34
- subscribe(next: (event: any) => void) {
35
- listener = next;
36
- return () => { listener = undefined; };
37
- },
38
- } as any;
39
-
40
- const cleanup = streamToOutputFile(session, outputPath, "agent-1", dir, historyPath);
41
- listener?.({ type: "turn_end" });
42
- cleanup();
43
-
44
- for (const path of [outputPath, historyPath]) {
45
- const entries = (await readFile(path, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
46
- expect(entries).toHaveLength(2);
47
- expect(entries[1].message.details).toEqual({ diff: "+1 new", patch: "+new" });
48
- }
49
- });
50
- });
@@ -1,172 +0,0 @@
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
-
162
- it("exposes a safe render-only refresh target", () => {
163
- const running = makeRecord({ id: "running", status: "running", completedAt: undefined });
164
- const { widget, tui } = createWidgetHarness([running]);
165
- tui.requestRender.mockClear();
166
-
167
- expect((widget as any).requestUiRefresh(true)).toBe(true);
168
- expect(tui.requestRender).toHaveBeenCalledWith(true);
169
- widget.dispose();
170
- expect((widget as any).requestUiRefresh(true)).toBe(false);
171
- });
172
- });
@@ -1,103 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { formatConversationMessages } from "../../src/ui/conversation-blocks.js";
3
-
4
- describe("formatConversationMessages", () => {
5
- it("keeps markdown source for user and assistant blocks", () => {
6
- const blocks = formatConversationMessages([
7
- { role: "user", content: "# Ask\n\nUse `x`." },
8
- { role: "assistant", content: [
9
- { type: "text", text: "## Answer\n\n```ts\nconst x: number = 1;\n```" },
10
- ] },
11
- ] as any);
12
-
13
- expect(blocks.filter((b) => b.kind === "text").map((b) => b.markdown)).toEqual([
14
- "# Ask\n\nUse `x`.",
15
- "## Answer\n\n```ts\nconst x: number = 1;\n```",
16
- ]);
17
- });
18
-
19
- it("separates assistant tool calls and bounds tool results", () => {
20
- const blocks = formatConversationMessages([
21
- { role: "assistant", content: [
22
- { type: "toolCall", name: "read", arguments: { path: "src/index.ts" } },
23
- ] },
24
- { role: "toolResult", toolName: "read", isError: false, content: [
25
- { type: "text", text: "a".repeat(100) },
26
- ] },
27
- ] as any, { maxToolResultChars: 40 });
28
-
29
- expect(blocks.some((b) => b.toolLine?.includes("read"))).toBe(true);
30
- const result = blocks.find((b) => b.role === "tool" && b.fullText.includes("read"));
31
- expect(result?.fullText.length).toBeLessThanOrEqual(40);
32
- });
33
-
34
- it("pairs tool results by id, then same-name source order, and preserves orphans", () => {
35
- const blocks = formatConversationMessages([
36
- { role: "assistant", content: [
37
- { type: "toolCall", id: "call-read", name: "read", arguments: { path: "a.ts" } },
38
- { type: "toolCall", id: "call-write", name: "write", arguments: { path: "b.ts", content: "new" } },
39
- ] },
40
- { role: "toolResult", toolCallId: "call-read", toolName: "read", isError: false, details: { source: "id" }, content: [{ type: "text", text: "read output" }] },
41
- { role: "toolResult", toolName: "write", isError: true, details: { source: "name" }, content: [{ type: "text", text: "write failed" }] },
42
- { role: "toolResult", toolCallId: "missing", toolName: "bash", isError: true, content: [{ type: "text", text: "orphan" }] },
43
- ] as any);
44
-
45
- const tools = blocks.filter((block) => block.kind === "tool");
46
- expect(tools).toHaveLength(3);
47
- expect(tools[0]?.toolCallId).toBe("call-read");
48
- expect(tools[0]?.toolResult?.details).toEqual({ source: "id" });
49
- expect(tools[1]?.toolCallId).toBe("call-write");
50
- expect(tools[1]?.toolResult?.isError).toBe(true);
51
- expect(tools[2]?.toolResult?.content).toEqual([{ type: "text", text: "orphan" }]);
52
- expect(tools[2]?.fullText).toContain("orphan");
53
- });
54
-
55
- it("uses a stable synthetic id when a tool call omits its id", () => {
56
- const blocks = formatConversationMessages([
57
- { role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "a.ts" } }] },
58
- { role: "toolResult", toolName: "read", content: [{ type: "text", text: "ok" }] },
59
- ] as any);
60
-
61
- expect(blocks[0]?.toolCallId).toBe("message-0-tool-0");
62
- expect(blocks[0]?.toolResult?.content).toEqual([{ type: "text", text: "ok" }]);
63
- });
64
-
65
- it("normalizes CRLF and bounds text with a deterministic truncation marker", () => {
66
- const blocks = formatConversationMessages([
67
- { role: "user", content: "one\r\ntwo\rthree\nfour" },
68
- ] as any, { maxEntryChars: 12 });
69
-
70
- expect(blocks[0]?.markdown).toBe("\n… [truncate");
71
- expect(blocks[0]?.id).toBe("message-0");
72
- expect(blocks[0]?.fullText.length).toBeLessThanOrEqual(12);
73
- });
74
-
75
- it("formats bash, images, summaries, and deterministic tool ids", () => {
76
- const blocks = formatConversationMessages([
77
- { role: "assistant", content: [
78
- { type: "text", text: "answer" },
79
- { type: "toolCall", name: "read", arguments: { path: "a.ts" } },
80
- { type: "toolCall", name: "write", arguments: { path: "b.ts" } },
81
- ] },
82
- { role: "bashExecution", command: "echo hi", output: "hi" },
83
- { role: "custom", customType: "note", content: [{ type: "image" }] },
84
- { role: "compactionSummary", summary: "Earlier context" },
85
- ] as any);
86
-
87
- expect(blocks.map((block) => block.id)).toEqual([
88
- "message-0", "message-0-tool-0", "message-0-tool-1", "message-1", "message-2", "message-3",
89
- ]);
90
- expect(blocks[1]?.toolLine).toContain("· read → a.ts");
91
- expect(blocks[3]?.fullText).toContain("$ echo hi\nhi");
92
- expect(blocks[4]?.markdown).toBe("[image]");
93
- expect(blocks[5]?.role).toBe("meta");
94
- });
95
-
96
- it("skips unsupported or empty messages without throwing", () => {
97
- expect(formatConversationMessages([
98
- { role: "unknown", content: { not: "text" } },
99
- { role: "user", content: [{ type: "thinking", thinking: "hidden" }] },
100
- { role: "custom", display: false, content: "hidden" },
101
- ] as any)).toEqual([]);
102
- });
103
- });