Open History to see the last commit
1import type { PluggableList } from "unified";
2import type { Root, RootContent } from "mdast";
3import {
4 resolveWorkEntryToolPresentation,
5 toolGroupAction,
6} from "@t3tools/client-runtime/work-log/presentation";
7import { isJavaScriptReplTool } from "@t3tools/shared/toolActivity";
8import { formatWorkspaceRelativePath } from "../../filePathDisplay";
9import { type TimelineWorkEntry, type TimelineRow } from "./timelineRowContext";
10import { type WorkEntryIconName } from "./TimelineToolActivityIcon";
11
12export function toolGroupSummaryIconName(
13 kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
14): WorkEntryIconName {
15 switch (kind) {
16 case "pull-request":
17 case "link-pr":
18 case "unlink-pr":
19 case "list-prs":
20 case "watch-pr":
21 case "unwatch-pr":
22 return "pull-request";
23 case "read":
24 return "eye";
25 case "edit":
26 return "square-pen";
27 case "command":
28 return "terminal";
29 case "thread-create":
30 return "t3-code";
31 case "browser":
32 return "browser";
33 case "device":
34 return "device";
35 case "search":
36 return "globe";
37 case "code-search":
38 return "search";
39 case "other":
40 return "wrench";
41 case "dynamic-tool":
42 return "hammer";
43 case "reasoning":
44 return "brain";
45 case "agent-tool":
46 return "bot";
47 case "tone-tool":
48 return "zap";
49 case "update":
50 case "mixed":
51 return "hammer";
52 }
53}
54
55export function workToneIcon(tone: TimelineWorkEntry["tone"]): {
56 iconName: WorkEntryIconName;
57 className: string;
58} {
59 if (tone === "error") {
60 return {
61 iconName: "circle-alert",
62 className: "text-foreground/92",
63 };
64 }
65 if (tone === "thinking") {
66 return {
67 iconName: "brain",
68 className: "text-icon-muted",
69 };
70 }
71 if (tone === "info") {
72 return {
73 iconName: "check",
74 className: "text-muted-foreground",
75 };
76 }
77 return {
78 iconName: "zap",
79 className: "text-foreground/92",
80 };
81}
82
83export function workEntryRawCommand(
84 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
85): string | null {
86 const rawCommand = workEntry.rawCommand?.trim();
87 if (!rawCommand || !workEntry.command) {
88 return null;
89 }
90 return rawCommand === workEntry.command.trim() ? null : rawCommand;
91}
92
93export function buildToolCallExpandedBody(
94 workEntry: TimelineWorkEntry,
95 workspaceRoot: string | undefined,
96 visibleLabel: string,
97 viewedImagePath: string | null,
98): string | null {
99 const blocks: string[] = [];
100 const seen = new Set<string>([visibleLabel.trim()]);
101 const addBlock = (value: string | null | undefined) => {
102 const text = value?.trim();
103 if (!text || seen.has(text)) return;
104 seen.add(text);
105 blocks.push(text);
106 };
107 if (workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) {
108 const input =
109 workEntry.structuredPayload?.type === "dynamic_tool"
110 ? workEntry.structuredPayload.input
111 : workEntry.toolData !== null &&
112 typeof workEntry.toolData === "object" &&
113 "input" in workEntry.toolData
114 ? workEntry.toolData.input
115 : undefined;
116 if (input !== undefined) addBlock(`Tool input\n${JSON.stringify(input, null, 2)}`);
117 }
118 const command = workEntry.command?.trim();
119 const raw = workEntryRawCommand(workEntry);
120 if (command === visibleLabel.trim()) {
121 seen.add(command);
122 } else {
123 addBlock(raw ?? command);
124 }
125 const detail = workEntry.detail?.trim();
126 if (detail !== viewedImagePath?.trim()) {
127 addBlock(detail);
128 }
129 const viewedImagePaths = new Set(
130 viewedImagePath
131 ? [viewedImagePath.trim(), formatWorkspaceRelativePath(viewedImagePath, workspaceRoot)]
132 : [],
133 );
134 const changedFiles = (workEntry.changedFiles ?? []).flatMap((filePath) => {
135 const formattedPath = formatWorkspaceRelativePath(filePath, workspaceRoot);
136 return viewedImagePaths.has(filePath) ||
137 viewedImagePaths.has(formattedPath) ||
138 filePath.trim() === detail ||
139 formattedPath === detail
140 ? []
141 : [formattedPath];
142 });
143 if (changedFiles.length > 0) {
144 addBlock([...new Set(changedFiles)].join("\n"));
145 }
146 return blocks.length > 0 ? blocks.join("\n\n") : null;
147}
148
149export function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
150 if (workEntry.structuredPayload?.type === "notification") {
151 if (workEntry.structuredPayload.outcome === "failed") return "circle-alert";
152 const source = workEntry.structuredPayload.source;
153 switch (source.kind) {
154 case "subagent":
155 case "delegated_task":
156 return "bot";
157 case "command":
158 return "terminal";
159 case "monitor":
160 return "eye";
161 case "background_task":
162 return "zap";
163 case "system":
164 return "t3-code";
165 default:
166 source satisfies never;
167 return "zap";
168 }
169 }
170 if (workEntry.itemType === "user_input_request" || workEntry.itemType === "approval_request") {
171 return "message-circle";
172 }
173 if (workEntry.itemType === "todo_list") return "list-todo";
174 if (workEntry.toolSurface) return workEntry.toolSurface;
175 if (
176 workEntry.structuredPayload?.type === "dynamic_tool" &&
177 isJavaScriptReplTool(workEntry.structuredPayload.toolName)
178 )
179 return "code";
180 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
181 if (toolPresentation) return toolPresentation.icon;
182 const action = toolGroupAction(workEntry);
183 if (action !== "other") return toolGroupSummaryIconName(action);
184
185 switch (workEntry.itemType) {
186 case "dynamic_tool":
187 return "wrench";
188 case "subagent":
189 return "bot";
190 }
191
192 return workToneIcon(workEntry.tone).iconName;
193}
194
195// Look past the visible label so long destinations and inline delimiters can close.
196// This is bounded lookahead, not document-wide Markdown parity: a closing delimiter
197// or reference definition beyond this prefix remains unresolved by the parser.
198export const THOUGHT_PREVIEW_SOURCE_MAX_LENGTH = 8_192;
199export const THOUGHT_PREVIEW_MAX_LENGTH = 2_048;
200
201/** Compact reasoning keeps formulas while flattening other Markdown into label text. */
202export function remarkThoughtPreview(fallback: string) {
203 return (tree: Root) => {
204 const children: RootContent[] = [];
205 let remaining = THOUGHT_PREVIEW_MAX_LENGTH;
206 let fragments: string[] = [];
207 const flush = () => {
208 if (fragments.length === 0) return;
209 children.push({ type: "text", value: fragments.join("").replace(/\s+/g, " ") });
210 fragments = [];
211 };
212 const take = (value: string) => {
213 const text = value.slice(0, remaining);
214 remaining = text.length < value.length ? 0 : remaining - text.length;
215 // A cut here or at the source limit must not leave half a surrogate pair.
216 return text.replace(/[\uD800-\uDBFF]$/, "");
217 };
218 const append = (value: string) => {
219 fragments.push(take(value));
220 };
221 const visit = (node: Root | RootContent) => {
222 if (remaining === 0 || node.type === "html" || node.type === "definition") return;
223 if (node.type === "math" || node.type === "inlineMath") {
224 flush();
225 const value = take(node.value);
226 children.push({
227 type: "inlineMath",
228 value,
229 position: node.position,
230 data: {
231 hName: "code",
232 hProperties: { className: ["language-math", "math-inline"] },
233 hChildren: [{ type: "text", value }],
234 },
235 });
236 } else if ("alt" in node) append(node.alt ?? "");
237 else if ("value" in node) append(node.value);
238 else if ("children" in node) {
239 for (const [index, child] of node.children.entries()) {
240 if (remaining === 0) break;
241 if (
242 index &&
243 ["root", "blockquote", "list", "listItem", "table", "tableRow"].includes(node.type)
244 )
245 append(" ");
246 visit(child);
247 }
248 } else if (node.type === "break") append(" ");
249 };
250 visit(tree);
251 flush();
252 const first = children[0];
253 const last = children.at(-1);
254 if (first?.type === "text") first.value = first.value.trimStart();
255 if (last?.type === "text") last.value = last.value.trimEnd();
256 tree.children = children.some((child) => child.type !== "text" || child.value)
257 ? children
258 : [{ type: "text", value: fallback }];
259 };
260}
261
262export const THOUGHT_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thought"]];
263export const THINKING_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thinking"]];