1import type { AssetResource, OrchestrationV2TurnItem } from "@t3tools/contracts";
2import { readToolOutputImage, toolOutputImages } from "@t3tools/shared/toolOutput";
3import * as DateTime from "effect/DateTime";
4
5const MAX_TEXT_BLOCK_DEPTH = 4;
6
7function isRecord(value: unknown): value is Record<string, unknown> {
8 return typeof value === "object" && value !== null && !Array.isArray(value);
9}
10
11/** Wire projection replaces a large dynamic input with `{ summary, truncated: true }`. */
12function isSummarizedValue(value: unknown): boolean {
13 return isRecord(value) && value.truncated === true && typeof value.summary === "string";
14}
15
16function textFromBlocks(value: unknown, depth: number): string | null {
17 if (depth > MAX_TEXT_BLOCK_DEPTH) return null;
18 if (typeof value === "string") return value;
19 if (Array.isArray(value)) {
20 const parts = value.map((block) => textFromBlocks(block, depth + 1));
21 return parts.every((part) => part !== null)
22 ? parts.filter((part) => part !== "").join("\n")
23 : null;
24 }
25 if (!isRecord(value)) return null;
26 if (value.type === "text" && typeof value.text === "string") return value.text;
27 // Images clients can show render on their own (see turnItemOutputImages).
28 if (value.type === "image") return readToolOutputImage(value) ? "" : "[image]";
29 if (value.type === "resource_link" && typeof value.uri === "string") return value.uri;
30 if (value.type === "resource" && isRecord(value.resource)) {
31 const resource = value.resource;
32 if (typeof resource.text === "string") return resource.text;
33 if (typeof resource.uri === "string") return resource.uri;
34 }
35 const keys = Object.keys(value).filter((key) => key !== "isError" && key !== "is_error");
36 // MCP results and provider tool results wrap their text in `content`.
37 // `structuredContent` usually repeats it as data, so it only shows when the
38 // text is empty.
39 if (keys.length === 1 && keys[0] === "content") return textFromBlocks(value.content, depth + 1);
40 if (keys.length === 2 && keys.includes("content") && keys.includes("structuredContent")) {
41 const text = textFromBlocks(value.content, depth + 1);
42 return text?.trim() ? text : null;
43 }
44 return null;
45}
46
47function parseJson(text: string): unknown {
48 try {
49 return JSON.parse(text);
50 } catch {
51 return undefined;
52 }
53}
54
55/** MCP tools often return JSON as minified text, one document per line. Indent each. */
56function prettyJsonText(text: string): string {
57 const trimmed = text.trim();
58 if (!/^[[{]/.test(trimmed)) return text;
59 const whole = parseJson(trimmed);
60 if (whole !== undefined) return JSON.stringify(whole, null, 2);
61 const lines = trimmed.split("\n").filter((line) => line.trim());
62 const documents = lines.map((line) => parseJson(line.trim()));
63 if (documents.some((document) => document === undefined)) return text;
64 return documents.map((document) => JSON.stringify(document, null, 2)).join("\n\n");
65}
66
67/** Formats a tool input or output for display: text blocks as text, the rest as JSON. */
68function formatToolValue(value: unknown, hasImagePreview = false): string | null {
69 if (value === undefined || value === null) return null;
70 // Claude Read returns one structured image; lists may contain other images.
71 if (hasImagePreview && isRecord(value) && value.type === "image") return null;
72 const text = textFromBlocks(value, 0);
73 if (text !== null) return text.trim() ? prettyJsonText(text) : null;
74 let json: string | undefined;
75 try {
76 json = JSON.stringify(value, null, 2);
77 } catch {
78 return String(value);
79 }
80 if (json === undefined || json === "{}" || json === "[]") return null;
81 return json;
82}
83
84/**
85 * The call a tool row's body shows above its result: the full command, the
86 * arguments as `key value` pairs, or formatted text when they are not flat.
87 */
88export function toolCallLines(input: {
89 readonly command?: string | undefined;
90 readonly args?: unknown;
91}): {
92 readonly command: string | null;
93 readonly args: ReadonlyArray<readonly [string, string]> | null;
94 readonly argsText: string | null;
95} {
96 if (input.command !== undefined) {
97 // The row title truncates to its width, so the body always has the full command.
98 const command = input.command.trim();
99 return { command: command || null, args: null, argsText: null };
100 }
101 const args = input.args;
102 if (isRecord(args) && !isSummarizedValue(args)) {
103 const entries = Object.entries(args).flatMap(
104 ([key, value]): Array<readonly [string, string]> => {
105 if (value === undefined) return [];
106 // An empty string or null can be the point of a call (a clear or reset), so show it.
107 return [
108 [
109 key,
110 typeof value === "string" && value !== ""
111 ? value
112 : (JSON.stringify(value) ?? String(value)),
113 ],
114 ];
115 },
116 );
117 return { command: null, args: entries.length > 0 ? entries : null, argsText: null };
118 }
119 return { command: null, args: null, argsText: formatToolValue(args) };
120}
121
122function toolCallHasLines(lines: ReturnType<typeof toolCallLines>): boolean {
123 return lines.command !== null || lines.args !== null || lines.argsText !== null;
124}
125
126const LIVE_TURN_ITEM_STATUSES: ReadonlySet<OrchestrationV2TurnItem["status"]> = new Set([
127 "idle",
128 "pending",
129 "running",
130 "waiting",
131]);
132
133/**
134 * Cache key for a fetched item. A running item keeps one key, so an open row
135 * fetches once while it streams and again when it finishes, not on every update.
136 */
137export function turnItemDetailRevision(item: OrchestrationV2TurnItem): string {
138 return LIVE_TURN_ITEM_STATUSES.has(item.status) ? "live" : DateTime.formatIso(item.updatedAt);
139}
140
141/** True when the timeline item withholds content that `getTurnItem` returns. */
142export function turnItemNeedsDetailFetch(item: OrchestrationV2TurnItem): boolean {
143 switch (item.type) {
144 case "command_execution":
145 return item.outputOmitted === true;
146 case "dynamic_tool":
147 return item.outputOmitted === true || isSummarizedValue(item.input);
148 default:
149 return false;
150 }
151}
152
153/** Older Claude bash rows stored the raw `{ stdout, stderr, interrupted, ... }` result. */
154function commandOutputText(output: string): string {
155 if (!output.trimStart().startsWith('{"stdout"')) return output;
156 const parsed = parseJson(output.trim());
157 if (
158 !isRecord(parsed) ||
159 typeof parsed.stdout !== "string" ||
160 typeof parsed.stderr !== "string" ||
161 typeof parsed.interrupted !== "boolean"
162 ) {
163 return output;
164 }
165 return [parsed.stdout, parsed.stderr]
166 .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
167 .join("\n");
168}
169
170/** The tool output carried by a fetched item, formatted for display. */
171export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null {
172 switch (item.type) {
173 case "command_execution":
174 return item.output?.trim() ? commandOutputText(item.output) || null : null;
175 case "dynamic_tool":
176 return item.outputOmitted === true
177 ? null
178 : formatToolValue(item.output, item.viewedImagePath !== undefined);
179 case "file_search":
180 return item.results?.length
181 ? item.results
182 .map((result) =>
183 [
184 `${result.fileName}${result.line === undefined ? "" : `:${result.line}`}`,
185 result.preview?.trim(),
186 ]
187 .filter(Boolean)
188 .join("\n"),
189 )
190 .join("\n")
191 : null;
192 case "web_search":
193 return item.results?.length
194 ? item.results
195 .map((result) =>
196 [
197 result.title?.trim() || result.url,
198 result.title ? result.url : undefined,
199 result.snippet?.trim(),
200 ]
201 .filter(Boolean)
202 .join("\n"),
203 )
204 .join("\n\n")
205 : null;
206 default:
207 return null;
208 }
209}
210
211/**
212 * Images in a fetched item's tool output, as assets. The detail read leaves
213 * the bytes out, so each loads over HTTP by its index.
214 */
215export function turnItemOutputImages(
216 item: OrchestrationV2TurnItem,
217): ReadonlyArray<Extract<AssetResource, { readonly _tag: "tool-output-image" }>> {
218 if (item.type !== "dynamic_tool" || item.outputOmitted === true) return [];
219 return toolOutputImages(item.output).map((_, index) => ({
220 _tag: "tool-output-image",
221 threadId: item.threadId,
222 itemId: item.id,
223 index,
224 }));
225}
226
227/**
228 * Whether expanding the item shows anything. Rows without content must not
229 * offer a disclosure, otherwise they open to an empty panel.
230 */
231export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean {
232 switch (item.type) {
233 case "reasoning":
234 return item.text.trim().length > 0;
235 case "command_execution":
236 return (
237 toolCallLines({ command: item.input }).command !== null ||
238 item.outputOmitted === true ||
239 Boolean(item.output?.trim()) ||
240 (item.exitCode !== undefined && item.exitCode !== 0)
241 );
242 case "file_change":
243 case "checkpoint":
244 case "fork":
245 case "handoff":
246 return true;
247 case "file_search":
248 return (item.results?.length ?? 0) > 0 || Boolean(item.pattern?.trim());
249 case "web_search":
250 return (item.results?.length ?? 0) > 0 || (item.patterns?.length ?? 0) > 0;
251 case "dynamic_tool":
252 return item.outputOmitted === true || toolCallHasLines(toolCallLines({ args: item.input }));
253 case "approval_request":
254 return Boolean(item.prompt?.trim());
255 case "user_input_request":
256 return item.questions.length > 0;
257 case "notification":
258 return Boolean(item.detail?.trim());
259 case "system_notice":
260 return item.message.trim().length > 0;
261 case "error":
262 return item.failure.message.trim().length > 0;
263 case "proposed_plan":
264 return item.markdown.trim().length > 0;
265 case "todo_list":
266 return item.steps.length > 0;
267 case "subagent":
268 return item.childThreadId !== null;
269 default:
270 return false;
271 }
272}