@esso0428/pi-subagents 0.15.5 → 0.15.7

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 (234) hide show
  1. package/CHANGELOG.md +10 -19
  2. package/README.md +5 -5
  3. package/dist/agent-history-list.d.ts +19 -0
  4. package/dist/agent-history-list.d.ts.map +1 -0
  5. package/dist/agent-history-list.js +69 -0
  6. package/dist/agent-history-list.js.map +1 -0
  7. package/dist/agent-history.d.ts +2 -1
  8. package/dist/agent-history.d.ts.map +1 -0
  9. package/dist/agent-history.js +32 -34
  10. package/dist/agent-history.js.map +1 -0
  11. package/dist/agent-manager.d.ts +24 -8
  12. package/dist/agent-manager.d.ts.map +1 -0
  13. package/dist/agent-manager.js +171 -93
  14. package/dist/agent-manager.js.map +1 -0
  15. package/dist/agent-runner.d.ts +1 -0
  16. package/dist/agent-runner.d.ts.map +1 -0
  17. package/dist/agent-runner.js +75 -86
  18. package/dist/agent-runner.js.map +1 -0
  19. package/dist/agent-types.d.ts +1 -0
  20. package/dist/agent-types.d.ts.map +1 -0
  21. package/dist/agent-types.js +28 -45
  22. package/dist/agent-types.js.map +1 -0
  23. package/dist/context.d.ts +1 -0
  24. package/dist/context.d.ts.map +1 -0
  25. package/dist/context.js +3 -6
  26. package/dist/context.js.map +1 -0
  27. package/dist/cross-extension-rpc.d.ts +1 -0
  28. package/dist/cross-extension-rpc.d.ts.map +1 -0
  29. package/dist/cross-extension-rpc.js +6 -9
  30. package/dist/cross-extension-rpc.js.map +1 -0
  31. package/dist/custom-agents.d.ts +1 -0
  32. package/dist/custom-agents.d.ts.map +1 -0
  33. package/dist/custom-agents.js +16 -18
  34. package/dist/custom-agents.js.map +1 -0
  35. package/dist/default-agents.d.ts +1 -0
  36. package/dist/default-agents.d.ts.map +1 -0
  37. package/dist/default-agents.js +2 -4
  38. package/dist/default-agents.js.map +1 -0
  39. package/dist/enabled-models.d.ts +1 -0
  40. package/dist/enabled-models.d.ts.map +1 -0
  41. package/dist/enabled-models.js +12 -16
  42. package/dist/enabled-models.js.map +1 -0
  43. package/dist/env.d.ts +1 -0
  44. package/dist/env.d.ts.map +1 -0
  45. package/dist/env.js +2 -4
  46. package/dist/env.js.map +1 -0
  47. package/dist/group-join.d.ts +1 -0
  48. package/dist/group-join.d.ts.map +1 -0
  49. package/dist/group-join.js +2 -5
  50. package/dist/group-join.js.map +1 -0
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +306 -292
  54. package/dist/index.js.map +1 -0
  55. package/dist/invocation-config.d.ts +1 -0
  56. package/dist/invocation-config.d.ts.map +1 -0
  57. package/dist/invocation-config.js +3 -6
  58. package/dist/invocation-config.js.map +1 -0
  59. package/dist/memory.d.ts +1 -0
  60. package/dist/memory.d.ts.map +1 -0
  61. package/dist/memory.js +25 -34
  62. package/dist/memory.js.map +1 -0
  63. package/dist/model-resolver.d.ts +1 -0
  64. package/dist/model-resolver.d.ts.map +1 -0
  65. package/dist/model-resolver.js +2 -4
  66. package/dist/model-resolver.js.map +1 -0
  67. package/dist/nico-overrides.d.ts +1 -0
  68. package/dist/nico-overrides.d.ts.map +1 -0
  69. package/dist/nico-overrides.js +15 -20
  70. package/dist/nico-overrides.js.map +1 -0
  71. package/dist/output-file.d.ts +1 -0
  72. package/dist/output-file.d.ts.map +1 -0
  73. package/dist/output-file.js +17 -22
  74. package/dist/output-file.js.map +1 -0
  75. package/dist/prompts.d.ts +1 -0
  76. package/dist/prompts.d.ts.map +1 -0
  77. package/dist/prompts.js +2 -4
  78. package/dist/prompts.js.map +1 -0
  79. package/dist/schedule-store.d.ts +1 -0
  80. package/dist/schedule-store.d.ts.map +1 -0
  81. package/dist/schedule-store.js +17 -21
  82. package/dist/schedule-store.js.map +1 -0
  83. package/dist/schedule.d.ts +1 -0
  84. package/dist/schedule.d.ts.map +1 -0
  85. package/dist/schedule.js +9 -12
  86. package/dist/schedule.js.map +1 -0
  87. package/dist/settings.d.ts +1 -0
  88. package/dist/settings.d.ts.map +1 -0
  89. package/dist/settings.js +16 -23
  90. package/dist/settings.js.map +1 -0
  91. package/dist/skill-loader.d.ts +1 -0
  92. package/dist/skill-loader.d.ts.map +1 -0
  93. package/dist/skill-loader.js +21 -23
  94. package/dist/skill-loader.js.map +1 -0
  95. package/dist/status-note.d.ts +1 -0
  96. package/dist/status-note.d.ts.map +1 -0
  97. package/dist/status-note.js +2 -4
  98. package/dist/status-note.js.map +1 -0
  99. package/dist/types.d.ts +5 -1
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +2 -4
  102. package/dist/types.js.map +1 -0
  103. package/dist/ui/agent-widget.d.ts +2 -5
  104. package/dist/ui/agent-widget.d.ts.map +1 -0
  105. package/dist/ui/agent-widget.js +29 -51
  106. package/dist/ui/agent-widget.js.map +1 -0
  107. package/dist/ui/ccstyle/diff/ansi-utils.d.ts +11 -0
  108. package/dist/ui/ccstyle/diff/ansi-utils.d.ts.map +1 -0
  109. package/dist/ui/ccstyle/diff/ansi-utils.js +145 -0
  110. package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -0
  111. package/dist/ui/ccstyle/diff/diff-presentation.d.ts +12 -0
  112. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -0
  113. package/dist/ui/ccstyle/diff/diff-presentation.js +49 -0
  114. package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -0
  115. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +46 -0
  116. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -0
  117. package/dist/ui/ccstyle/diff/diff-renderer.js +2044 -0
  118. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -0
  119. package/dist/ui/ccstyle/diff/line-width-safety.d.ts +12 -0
  120. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -0
  121. package/dist/ui/ccstyle/diff/line-width-safety.js +59 -0
  122. package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -0
  123. package/dist/ui/ccstyle/diff/render-utils.d.ts +6 -0
  124. package/dist/ui/ccstyle/diff/render-utils.d.ts.map +1 -0
  125. package/dist/ui/ccstyle/diff/render-utils.js +25 -0
  126. package/dist/ui/ccstyle/diff/render-utils.js.map +1 -0
  127. package/dist/ui/ccstyle/diff/types.d.ts +22 -0
  128. package/dist/ui/ccstyle/diff/types.d.ts.map +1 -0
  129. package/dist/ui/ccstyle/diff/types.js +10 -0
  130. package/dist/ui/ccstyle/diff/types.js.map +1 -0
  131. package/dist/ui/ccstyle/diff/write-display-utils.d.ts +2 -0
  132. package/dist/ui/ccstyle/diff/write-display-utils.d.ts.map +1 -0
  133. package/dist/ui/ccstyle/diff/write-display-utils.js +12 -0
  134. package/dist/ui/ccstyle/diff/write-display-utils.js.map +1 -0
  135. package/dist/ui/ccstyle/tool-renderer.d.ts +17 -0
  136. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -0
  137. package/dist/ui/ccstyle/tool-renderer.js +58 -0
  138. package/dist/ui/ccstyle/tool-renderer.js.map +1 -0
  139. package/dist/ui/ccstyle/tool-result.d.ts +24 -0
  140. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -0
  141. package/dist/ui/ccstyle/tool-result.js +193 -0
  142. package/dist/ui/ccstyle/tool-result.js.map +1 -0
  143. package/dist/ui/conversation-blocks.d.ts +46 -0
  144. package/dist/ui/conversation-blocks.d.ts.map +1 -0
  145. package/dist/ui/conversation-blocks.js +306 -0
  146. package/dist/ui/conversation-blocks.js.map +1 -0
  147. package/dist/ui/conversation-nvim.d.ts +8 -0
  148. package/dist/ui/conversation-nvim.d.ts.map +1 -0
  149. package/dist/ui/conversation-nvim.js +117 -0
  150. package/dist/ui/conversation-nvim.js.map +1 -0
  151. package/dist/ui/conversation-role.d.ts +13 -0
  152. package/dist/ui/conversation-role.d.ts.map +1 -0
  153. package/dist/ui/conversation-role.js +39 -0
  154. package/dist/ui/conversation-role.js.map +1 -0
  155. package/dist/ui/conversation-search.d.ts +39 -0
  156. package/dist/ui/conversation-search.d.ts.map +1 -0
  157. package/dist/ui/conversation-search.js +124 -0
  158. package/dist/ui/conversation-search.js.map +1 -0
  159. package/dist/ui/conversation-timeline.d.ts +62 -0
  160. package/dist/ui/conversation-timeline.d.ts.map +1 -0
  161. package/dist/ui/conversation-timeline.js +263 -0
  162. package/dist/ui/conversation-timeline.js.map +1 -0
  163. package/dist/ui/conversation-viewer.d.ts +62 -20
  164. package/dist/ui/conversation-viewer.d.ts.map +1 -0
  165. package/dist/ui/conversation-viewer.js +449 -224
  166. package/dist/ui/conversation-viewer.js.map +1 -0
  167. package/dist/ui/fleet-list.d.ts +24 -11
  168. package/dist/ui/fleet-list.d.ts.map +1 -0
  169. package/dist/ui/fleet-list.js +86 -78
  170. package/dist/ui/fleet-list.js.map +1 -0
  171. package/dist/ui/schedule-menu.d.ts +1 -0
  172. package/dist/ui/schedule-menu.d.ts.map +1 -0
  173. package/dist/ui/schedule-menu.js +2 -4
  174. package/dist/ui/schedule-menu.js.map +1 -0
  175. package/dist/ui/viewer-keys.d.ts +1 -0
  176. package/dist/ui/viewer-keys.d.ts.map +1 -0
  177. package/dist/ui/viewer-keys.js +8 -10
  178. package/dist/ui/viewer-keys.js.map +1 -0
  179. package/dist/usage.d.ts +1 -0
  180. package/dist/usage.d.ts.map +1 -0
  181. package/dist/usage.js +5 -10
  182. package/dist/usage.js.map +1 -0
  183. package/dist/worktree.d.ts +1 -0
  184. package/dist/worktree.d.ts.map +1 -0
  185. package/dist/worktree.js +27 -31
  186. package/dist/worktree.js.map +1 -0
  187. package/dist/write-execution.d.ts +42 -0
  188. package/dist/write-execution.d.ts.map +1 -0
  189. package/dist/write-execution.js +138 -0
  190. package/dist/write-execution.js.map +1 -0
  191. package/package.json +1 -1
  192. package/src/agent-history-list.ts +91 -0
  193. package/src/agent-history.ts +12 -17
  194. package/src/agent-manager.ts +169 -81
  195. package/src/agent-runner.ts +12 -13
  196. package/src/index.ts +117 -98
  197. package/src/output-file.ts +1 -1
  198. package/src/types.ts +4 -1
  199. package/src/ui/agent-widget.ts +3 -9
  200. package/src/ui/ccstyle/ATTRIBUTION.md +37 -0
  201. package/src/ui/ccstyle/diff/ansi-utils.ts +168 -0
  202. package/src/ui/ccstyle/diff/diff-presentation.ts +70 -0
  203. package/src/ui/ccstyle/diff/diff-renderer.ts +3148 -0
  204. package/src/ui/ccstyle/diff/line-width-safety.ts +92 -0
  205. package/src/ui/ccstyle/diff/render-utils.ts +32 -0
  206. package/src/ui/ccstyle/diff/types.ts +33 -0
  207. package/src/ui/ccstyle/diff/write-display-utils.ts +12 -0
  208. package/src/ui/ccstyle/tool-renderer.ts +71 -0
  209. package/src/ui/ccstyle/tool-result.ts +238 -0
  210. package/src/ui/conversation-blocks.ts +404 -0
  211. package/src/ui/conversation-nvim.ts +127 -0
  212. package/src/ui/conversation-role.ts +45 -0
  213. package/src/ui/conversation-search.ts +161 -0
  214. package/src/ui/conversation-timeline.ts +315 -0
  215. package/src/ui/conversation-viewer.ts +418 -210
  216. package/src/ui/fleet-list.ts +73 -52
  217. package/src/write-execution.ts +180 -0
  218. package/test/agent-history-list.test.ts +121 -0
  219. package/test/agent-manager-history.test.ts +59 -58
  220. package/test/agent-menu-history-navigation.test.ts +86 -0
  221. package/test/output-file-history.test.ts +50 -0
  222. package/test/ui/conversation-blocks.test.ts +103 -0
  223. package/test/ui/conversation-diff.test.ts +91 -0
  224. package/test/ui/conversation-search.test.ts +55 -0
  225. package/test/ui/conversation-viewer.test.ts +182 -0
  226. package/test/ui/fleet-list.test.ts +131 -0
  227. package/test/write-execution.test.ts +83 -0
  228. package/tsconfig.json +16 -0
  229. package/vitest.temp.config.mts +6 -0
  230. package/dist/ui/markdown-result.d.ts +0 -3
  231. package/dist/ui/markdown-result.js +0 -53
  232. package/src/ui/markdown-result.ts +0 -56
  233. package/test/agent-history.test.ts +0 -63
  234. package/test/ui/markdown-result.test.ts +0 -45
@@ -0,0 +1,161 @@
1
+ /** Search and highlighting helpers for rendered conversation lines. */
2
+
3
+ /** A substring match in a rendered line's plain-text representation. */
4
+ export type ConversationMatch = {
5
+ lineIdx: number;
6
+ col: number;
7
+ len: number;
8
+ };
9
+
10
+ type ConversationLine = { plain: string };
11
+
12
+ type HighlightMatch = ConversationMatch & { current?: boolean };
13
+
14
+ function isSmartcaseInsensitive(query: string): boolean {
15
+ return query === query.toLowerCase();
16
+ }
17
+
18
+ /**
19
+ * Remove ANSI CSI/OSC control sequences from a string.
20
+ *
21
+ * This deliberately follows the escape-sequence grammar used by the terminal
22
+ * renderer, rather than stripping only colour codes: hyperlinks and other
23
+ * styled output must not affect plain-text search columns either.
24
+ */
25
+ export function stripAnsi(text: string): string {
26
+ let result = "";
27
+ let i = 0;
28
+
29
+ while (i < text.length) {
30
+ if (text[i] !== "\x1b") {
31
+ result += text[i];
32
+ i++;
33
+ continue;
34
+ }
35
+
36
+ const match = text.slice(i).match(ANSI_ESCAPE_RE);
37
+ if (match) {
38
+ i += match[0].length;
39
+ continue;
40
+ }
41
+
42
+ // An incomplete escape sequence has no visible representation. This also
43
+ // prevents a malformed trailing escape from leaking into search text.
44
+ if (i + 1 < text.length) {
45
+ i += 2;
46
+ } else {
47
+ i++;
48
+ }
49
+ }
50
+
51
+ return result;
52
+ }
53
+
54
+ /**
55
+ * Find all non-overlapping substring matches using smartcase semantics.
56
+ * Lowercase queries match case-insensitively; queries containing uppercase
57
+ * characters match case-sensitively.
58
+ */
59
+ export function findConversationMatches(
60
+ lines: ConversationLine[],
61
+ query: string,
62
+ ): ConversationMatch[] {
63
+ if (!query) return [];
64
+
65
+ const matches: ConversationMatch[] = [];
66
+ const caseInsensitive = isSmartcaseInsensitive(query);
67
+ const needle = caseInsensitive ? query.toLowerCase() : query;
68
+
69
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
70
+ const plain = lines[lineIdx]?.plain ?? "";
71
+ const haystack = caseInsensitive ? plain.toLowerCase() : plain;
72
+ let position = 0;
73
+
74
+ while (position < haystack.length) {
75
+ const col = haystack.indexOf(needle, position);
76
+ if (col === -1) break;
77
+ matches.push({ lineIdx, col, len: query.length });
78
+ position = col + Math.max(1, query.length);
79
+ }
80
+ }
81
+
82
+ return matches;
83
+ }
84
+
85
+ /**
86
+ * Inject match backgrounds into an ANSI-styled line without counting escape
87
+ * sequences as plain-text columns. Existing foreground/style sequences are
88
+ * copied verbatim, so syntax highlighting remains intact.
89
+ */
90
+ export function highlightConversationLine(
91
+ text: string,
92
+ matches: HighlightMatch[],
93
+ openMatch: string,
94
+ openCurrent: string,
95
+ close: string,
96
+ ): string {
97
+ if (matches.length === 0) return text;
98
+
99
+ const sorted = [...matches].sort((a, b) => a.col - b.col);
100
+ let result = "";
101
+ let plainCol = 0;
102
+ let i = 0;
103
+ let active: HighlightMatch | null = null;
104
+ let nextIdx = 0;
105
+
106
+ while (i < text.length) {
107
+ if (text[i] === "\x1b") {
108
+ const escape = text.slice(i).match(ANSI_ESCAPE_RE);
109
+ if (escape) {
110
+ result += escape[0];
111
+ i += escape[0].length;
112
+ continue;
113
+ }
114
+ }
115
+
116
+ if (!active && nextIdx < sorted.length && plainCol >= sorted[nextIdx].col) {
117
+ active = sorted[nextIdx];
118
+ result += active.current ? openCurrent : openMatch;
119
+ }
120
+
121
+ result += text[i];
122
+ plainCol++;
123
+ i++;
124
+
125
+ if (active && plainCol >= active.col + active.len) {
126
+ result += close;
127
+ active = null;
128
+ nextIdx++;
129
+ }
130
+ }
131
+
132
+ if (active) result += close;
133
+ return result;
134
+ }
135
+
136
+ /** Find the first match at or after a line, wrapping to the first match. */
137
+ export function nearestConversationMatchAfter(
138
+ matches: ConversationMatch[],
139
+ lineIdx: number,
140
+ ): number {
141
+ if (matches.length === 0) return -1;
142
+ for (let i = 0; i < matches.length; i++) {
143
+ if (matches[i].lineIdx >= lineIdx) return i;
144
+ }
145
+ return 0;
146
+ }
147
+
148
+ /** Find the last match at or before a line, wrapping to the last match. */
149
+ export function nearestConversationMatchBefore(
150
+ matches: ConversationMatch[],
151
+ lineIdx: number,
152
+ ): number {
153
+ if (matches.length === 0) return -1;
154
+ for (let i = matches.length - 1; i >= 0; i--) {
155
+ if (matches[i].lineIdx <= lineIdx) return i;
156
+ }
157
+ return matches.length - 1;
158
+ }
159
+
160
+ /** ANSI CSI/OSC sequences emitted by the TUI renderer. */
161
+ const ANSI_ESCAPE_RE = /^(?:\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))/;
@@ -0,0 +1,315 @@
1
+ import * as CodingAgent from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Markdown,
4
+ Text,
5
+ type Component,
6
+ type TUI,
7
+ type TuiMouseEvent,
8
+ truncateToWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
11
+ import type { AgentRecord } from "../types.js";
12
+ import type { Theme } from "./agent-widget.js";
13
+ import {
14
+ type ConversationBlock,
15
+ type ConversationToolResultSnapshot,
16
+ } from "./conversation-blocks.js";
17
+ import { renderConversationRoleHeader } from "./conversation-role.js";
18
+ import { stripAnsi } from "./conversation-search.js";
19
+ import {
20
+ createViewerCcstyleTool,
21
+ type ViewerToolRenderers,
22
+ } from "./ccstyle/tool-renderer.js";
23
+
24
+ export interface TimelineRenderLine {
25
+ text: string;
26
+ plain: string;
27
+ blockIndex: number;
28
+ railable?: boolean;
29
+ }
30
+
31
+ export interface TimelineToolSpan {
32
+ toolCallId: string;
33
+ startLine: number;
34
+ height: number;
35
+ }
36
+
37
+ export interface ConversationTimelineSnapshot {
38
+ lines: TimelineRenderLine[];
39
+ messageStarts: number[];
40
+ messageBlocks: ConversationBlock[];
41
+ interactiveToolIds: string[];
42
+ toolSpans: TimelineToolSpan[];
43
+ }
44
+
45
+ export interface ConversationTimelineOptions {
46
+ cwd?: string;
47
+ record?: AgentRecord;
48
+ onChange?: () => void;
49
+ }
50
+
51
+ function safeWidth(width: number): number {
52
+ return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
53
+ }
54
+
55
+ function resultContent(result: ConversationToolResultSnapshot): Array<Record<string, unknown>> {
56
+ if (Array.isArray(result.content)) return result.content as Array<Record<string, unknown>>;
57
+ if (typeof result.content === "string") return [{ type: "text", text: result.content }];
58
+ return [];
59
+ }
60
+
61
+ function fallbackToolLines(block: ConversationBlock, width: number, expanded = false): string[] {
62
+ const summary = block.toolLine || block.fullText || block.toolName || "tool";
63
+ const output = block.toolResult
64
+ ? block.toolResult.content
65
+ : undefined;
66
+ const outputText = typeof output === "string"
67
+ ? output
68
+ : Array.isArray(output)
69
+ ? output.filter((item: any) => item?.type === "text").map((item: any) => String(item.text ?? "")).join("\n")
70
+ : "";
71
+ const outputLines = outputText.split(/\r?\n/);
72
+ const displayOutput = expanded || outputLines.length <= 4
73
+ ? outputLines
74
+ : [...outputLines.slice(0, 4), `… (${outputLines.length - 4} more lines · Enter to expand)`];
75
+ const body = outputText ? [summary, ...displayOutput].join("\n") : summary;
76
+ return body.split(/\r?\n/).map((line) => truncateToWidth(line, width, ""));
77
+ }
78
+
79
+ function applyToolComponentState(component: any, block: ConversationBlock, expanded: boolean): void {
80
+ component.updateArgs?.(block.args ?? block.toolArguments ?? {});
81
+ component.setArgsComplete?.();
82
+ if (block.result ?? block.toolResult) {
83
+ const result = (block.result ?? block.toolResult)!;
84
+ component.updateResult?.({
85
+ content: resultContent(result),
86
+ details: result.details,
87
+ isError: result.isError === true,
88
+ }, false);
89
+ }
90
+ component.setExpanded?.(expanded);
91
+ }
92
+
93
+ function createToolComponent(
94
+ block: ConversationBlock,
95
+ tui: TUI,
96
+ cwd: string,
97
+ expanded: boolean,
98
+ ): Component | undefined {
99
+ const Constructor = (CodingAgent as any).ToolExecutionComponent as (new (...args: any[]) => Component) | undefined;
100
+ if (typeof Constructor !== "function" || !block.toolName || !block.toolCallId) return undefined;
101
+
102
+ try {
103
+ const renderer = createViewerCcstyleTool(block.toolName) as ViewerToolRenderers;
104
+ const component = new Constructor(
105
+ block.toolName,
106
+ block.toolCallId,
107
+ block.args ?? block.toolArguments ?? {},
108
+ { showImages: false },
109
+ renderer,
110
+ tui,
111
+ cwd,
112
+ ) as any;
113
+ component.markExecutionStarted?.();
114
+ applyToolComponentState(component, block, expanded);
115
+ return component;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Component-aware, snapshot-driven conversation timeline.
123
+ * ToolExecutionComponent state is rebuilt from normalized blocks when a call id
124
+ * first appears and then reused; history never needs a live session, spinner,
125
+ * stream, or process handle.
126
+ */
127
+ export class ConversationTimeline implements Component {
128
+ private blocks: ConversationBlock[] = [];
129
+ private showTools = true;
130
+ private focusedToolCallId: string | undefined;
131
+ private expandedTools = new Set<string>();
132
+ private toolComponents = new Map<string, Component & { handleMouse?: (event: TuiMouseEvent) => unknown }>();
133
+ private snapshot: ConversationTimelineSnapshot = {
134
+ lines: [],
135
+ messageStarts: [],
136
+ messageBlocks: [],
137
+ interactiveToolIds: [],
138
+ toolSpans: [],
139
+ };
140
+ private lastWidth = 0;
141
+
142
+ constructor(
143
+ private readonly tui: TUI,
144
+ private readonly theme: Theme,
145
+ private readonly options: ConversationTimelineOptions = {},
146
+ ) {}
147
+
148
+ setBlocks(blocks: readonly ConversationBlock[]): void {
149
+ this.blocks = [...blocks];
150
+ const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
151
+ if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
152
+ for (const id of this.expandedTools) if (!ids.has(id)) this.expandedTools.delete(id);
153
+ for (const id of this.toolComponents.keys()) if (!ids.has(id)) this.toolComponents.delete(id);
154
+ this.invalidate();
155
+ }
156
+
157
+ setShowTools(show: boolean): void {
158
+ if (this.showTools === show) return;
159
+ this.showTools = show;
160
+ if (!show) this.focusedToolCallId = undefined;
161
+ this.invalidate();
162
+ }
163
+
164
+ getShowTools(): boolean {
165
+ return this.showTools;
166
+ }
167
+
168
+ setFocusedToolCallId(id: string | undefined): void {
169
+ this.focusedToolCallId = id;
170
+ this.invalidate();
171
+ }
172
+
173
+ getFocusedToolCallId(): string | undefined {
174
+ return this.focusedToolCallId;
175
+ }
176
+
177
+ getSnapshot(): ConversationTimelineSnapshot {
178
+ if (this.lastWidth <= 0) this.render(80);
179
+ return this.snapshot;
180
+ }
181
+
182
+ moveFocus(direction: 1 | -1): boolean {
183
+ const ids = this.getSnapshot().interactiveToolIds;
184
+ if (ids.length === 0) return false;
185
+ const current = this.focusedToolCallId ? ids.indexOf(this.focusedToolCallId) : -1;
186
+ const next = current < 0
187
+ ? direction > 0 ? 0 : ids.length - 1
188
+ : (current + direction + ids.length) % ids.length;
189
+ this.focusedToolCallId = ids[next];
190
+ this.invalidate();
191
+ this.options.onChange?.();
192
+ return true;
193
+ }
194
+
195
+ toggleFocusedTool(): boolean {
196
+ const id = this.focusedToolCallId;
197
+ if (!id) return false;
198
+ if (this.expandedTools.has(id)) this.expandedTools.delete(id);
199
+ else this.expandedTools.add(id);
200
+ this.invalidate();
201
+ this.options.onChange?.();
202
+ return true;
203
+ }
204
+
205
+ isToolFocused(): boolean {
206
+ return this.focusedToolCallId !== undefined;
207
+ }
208
+
209
+ render(width: number): string[] {
210
+ const requestedWidth = safeWidth(width);
211
+ const lines: TimelineRenderLine[] = [];
212
+ const messageStarts: number[] = [];
213
+ const messageBlocks: ConversationBlock[] = [];
214
+ const interactiveToolIds: string[] = [];
215
+ const toolSpans: TimelineToolSpan[] = [];
216
+ const visibleBlocks = this.blocks.filter((block) => this.showTools || block.kind !== "tool");
217
+
218
+ if (visibleBlocks.length === 0) {
219
+ const text = this.blocks.length === 0 ? "(waiting for first message...)" : "(tools hidden)";
220
+ lines.push({ text: this.theme.fg("dim", text), plain: text, blockIndex: -1 });
221
+ }
222
+
223
+ for (const block of visibleBlocks) {
224
+ const blockIndex = this.blocks.indexOf(block);
225
+ if (lines.length > 0) {
226
+ const separator = this.theme.fg("border", " " + "─ ".repeat(Math.max(1, Math.floor((requestedWidth - 6) / 2))).trimEnd());
227
+ lines.push({ text: truncateToWidth(separator, requestedWidth), plain: stripAnsi(separator), blockIndex });
228
+ }
229
+
230
+ const headerLine = ` ${renderConversationRoleHeader(block, this.theme)}`;
231
+ messageStarts.push(lines.length);
232
+ messageBlocks.push(block);
233
+ lines.push({ text: truncateToWidth(headerLine, requestedWidth), plain: stripAnsi(headerLine), blockIndex, railable: true });
234
+
235
+ if (block.kind === "tool") {
236
+ const id = block.toolCallId;
237
+ if (id) interactiveToolIds.push(id);
238
+ let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
239
+ if (id) {
240
+ const existing = this.toolComponents.get(id);
241
+ if (existing) {
242
+ applyToolComponentState(existing, block, this.expandedTools.has(id));
243
+ component = existing;
244
+ } else {
245
+ component = createToolComponent(block, this.tui, this.options.cwd ?? process.cwd(), this.expandedTools.has(id)) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
246
+ if (component) this.toolComponents.set(id, component);
247
+ }
248
+ }
249
+ let rendered: string[];
250
+ try {
251
+ rendered = component?.render(requestedWidth) ?? fallbackToolLines(block, requestedWidth, id ? this.expandedTools.has(id) : false);
252
+ } catch {
253
+ // Pi's interactive theme is a capability, not a viewer dependency;
254
+ // test hosts and older Pi versions can lack its initialized theme.
255
+ rendered = fallbackToolLines(block, requestedWidth, id ? this.expandedTools.has(id) : false);
256
+ }
257
+ for (const line of rendered) {
258
+ lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
259
+ }
260
+ if (id) toolSpans.push({ toolCallId: id, startLine: messageStarts[messageStarts.length - 1], height: lines.length - messageStarts[messageStarts.length - 1] });
261
+ continue;
262
+ }
263
+
264
+ try {
265
+ const markdown = new Markdown(block.markdown || "∅", 2, 0, getMarkdownTheme(), {
266
+ color: (text) => this.theme.fg(block.role === "meta" ? "muted" : "text", text),
267
+ });
268
+ for (const line of markdown.render(requestedWidth)) {
269
+ lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
270
+ }
271
+ } catch {
272
+ try {
273
+ for (const line of new Text(block.markdown || "∅").render(requestedWidth)) {
274
+ lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
275
+ }
276
+ } catch {
277
+ for (const line of (block.markdown || "∅").split("\n")) {
278
+ lines.push({ text: truncateToWidth(line, requestedWidth), plain: line, blockIndex, railable: true });
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ this.lastWidth = requestedWidth;
285
+ this.snapshot = { lines, messageStarts, messageBlocks, interactiveToolIds, toolSpans };
286
+ return lines.map((line) => line.text);
287
+ }
288
+
289
+ handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
290
+ if (event.type !== "click" && event.type !== "press") return undefined;
291
+ const span = this.snapshot.toolSpans.find((candidate) => event.y >= candidate.startLine && event.y < candidate.startLine + candidate.height);
292
+ if (!span) return undefined;
293
+ this.focusedToolCallId = span.toolCallId;
294
+ const component = this.toolComponents.get(span.toolCallId);
295
+ try {
296
+ // Keep Pi's normalized mouse contract in the component tree. The local
297
+ // role header occupies the first row; ToolExecutionComponent receives
298
+ // the remaining card-relative coordinate.
299
+ component?.handleMouse?.({ ...event, y: event.y - span.startLine, height: span.height });
300
+ } catch {
301
+ // A missing/older Pi mouse implementation must not break the viewer.
302
+ }
303
+ this.expandedTools.has(span.toolCallId)
304
+ ? this.expandedTools.delete(span.toolCallId)
305
+ : this.expandedTools.add(span.toolCallId);
306
+ this.invalidate();
307
+ this.options.onChange?.();
308
+ return { handled: true, render: true };
309
+ }
310
+
311
+ invalidate(): void {
312
+ this.lastWidth = 0;
313
+ this.snapshot = { lines: [], messageStarts: [], messageBlocks: [], interactiveToolIds: [], toolSpans: [] };
314
+ }
315
+ }