| 1 | import type { AssetResource, OrchestrationV2TurnItem } from "@t3tools/contracts"; |
| 2 | import { readToolOutputImage, toolOutputImages } from "@t3tools/shared/toolOutput"; |
| 3 | import * as DateTime from "effect/DateTime"; |
| 4 | |
| 5 | const MAX_TEXT_BLOCK_DEPTH = 4; |
| 6 | |
| 7 | function 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 }`. */ |
| 12 | function isSummarizedValue(value: unknown): boolean { |
| 13 | return isRecord(value) && value.truncated === true && typeof value.summary === "string"; |
| 14 | } |
| 15 | |
| 16 | function 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 | |
| 47 | function 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. */ |
| 56 | function 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. */ |
| 68 | function 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 | */ |
| 88 | export 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 | |
| 122 | function toolCallHasLines(lines: ReturnType<typeof toolCallLines>): boolean { |
| 123 | return lines.command !== null || lines.args !== null || lines.argsText !== null; |
| 124 | } |
| 125 | |
| 126 | const 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 | */ |
| 137 | export 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. */ |
| 142 | export 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. */ |
| 154 | function 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. */ |
| 171 | export 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 | */ |
| 215 | export 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 | */ |
| 231 | export 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 | } |