diff --git a/.gitignore b/.gitignore index 00c3f22..a72cf01 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ vendor/opencode/ # OpenCode local deps .opencode/node_modules/ .opencode/bun.lock + +# Local notes +CLAUDE.md +openwork_security_audit.md diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 1c4f885..fa51d45 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -256,6 +256,7 @@ export default function App() { selectedSession, selectedSessionStatus, messages, + messageTimings, todos, pendingPermissions, permissionReplyBusy, @@ -266,6 +267,7 @@ export default function App() { selectSession, renameSession, respondPermission, + markSessionEndReason, setSessions, setSessionStatusById, setMessages, @@ -434,6 +436,23 @@ export default function App() { } } + async function cancelRun() { + const c = client(); + const sessionID = selectedSessionId(); + if (!c || !sessionID) return; + + try { + markSessionEndReason(sessionID, "interrupted"); + setBusy(false); + setBusyLabel(null); + setBusyStartedAt(null); + await c.session.abort({ sessionID }); + } catch (e) { + const message = e instanceof Error ? e.message : safeStringify(e); + setError(message); + } + } + async function renameSessionTitle(sessionID: string, title: string) { const trimmed = title.trim(); if (!trimmed) { @@ -2832,6 +2851,7 @@ export default function App() { })), selectSession: isDemoMode() ? selectDemoSession : selectSession, messages: activeMessages(), + messageTimings: isDemoMode() ? {} : messageTimings(), todos: activeTodos(), busyLabel: busyLabel(), developerMode: developerMode(), @@ -2848,6 +2868,7 @@ export default function App() { busy: busy(), prompt: prompt(), setPrompt: setPrompt, + cancelRun: cancelRun, activePermission: activePermissionMemo(), permissionReplyBusy: permissionReplyBusy(), respondPermission: respondPermission, diff --git a/packages/app/src/app/components/session/composer.tsx b/packages/app/src/app/components/session/composer.tsx index 8f1352b..0468112 100644 --- a/packages/app/src/app/components/session/composer.tsx +++ b/packages/app/src/app/components/session/composer.tsx @@ -1,6 +1,6 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import type { Agent } from "@opencode-ai/sdk/v2/client"; -import { ArrowRight, AtSign, ChevronDown, File, Paperclip, X, Zap } from "lucide-solid"; +import { ArrowRight, AtSign, ChevronDown, File, Paperclip, Square, X, Zap } from "lucide-solid"; import type { ComposerAttachment, ComposerDraft, ComposerPart, PromptMode } from "../../types"; @@ -28,6 +28,7 @@ type ComposerProps = { busy: boolean; onSend: (draft: ComposerDraft) => void; onDraftChange: (draft: ComposerDraft) => void; + onCancel: () => void; commandMatches: CommandItem[]; onRunCommand: (commandId: string) => void; onInsertCommand: (commandId: string) => void; @@ -1146,14 +1147,27 @@ export default function Composer(props: ComposerProps) { - + } > - - + + diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index c9e5501..568edb6 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -1,14 +1,15 @@ import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"; import type { JSX } from "solid-js"; import type { Part } from "@opencode-ai/sdk/v2/client"; -import { Check, ChevronDown, Circle, Copy, File } from "lucide-solid"; +import { Check, ChevronDown, Circle, Clock, Copy, Github } from "lucide-solid"; -import type { MessageGroup, MessageWithParts } from "../../types"; -import { groupMessageParts, summarizeStep } from "../../utils"; +import type { MessageEndReason, MessageGroup, MessageInfo, MessageTiming, MessageWithParts } from "../../types"; +import { formatElapsedTime, groupMessageParts, summarizeStep, type StepSummary } from "../../utils"; import PartView from "../part-view"; export type MessageListProps = { messages: MessageWithParts[]; + messageTimings: Record; developerMode: boolean; showThinking: boolean; expandedStepIds: Set; @@ -178,32 +179,138 @@ export default function MessageList(props: MessageListProps) { return blocks; }); + const messageInfoById = createMemo(() => { + const map = new Map(); + for (const message of props.messages) { + const id = String((message.info as any)?.id ?? ""); + if (id) { + map.set(id, message.info); + } + } + return map; + }); + + const reasonPriority: Record = { + terminated: 3, + interrupted: 2, + error: 2, + completed: 1, + }; + + const pickReason = ( + current: MessageEndReason | undefined, + next: MessageEndReason | undefined, + ) => { + if (!next) return current; + if (!current) return next; + return reasonPriority[next] > reasonPriority[current] ? next : current; + }; + + const resolveTimingRange = (messageId: string, info?: MessageInfo) => { + const timing = props.messageTimings[messageId]; + const created = (info as any)?.time?.created; + const completed = (info as any)?.time?.completed; + const start = timing?.startAt ?? (typeof created === "number" ? created : null); + const end = timing?.endAt ?? (typeof completed === "number" ? completed : null); + if (typeof start !== "number" || typeof end !== "number") return null; + const duration = end - start; + if (duration < 0) return null; + const reason = timing?.endReason ?? (typeof completed === "number" ? "completed" : undefined); + return { start, end, duration, reason }; + }; + + const resolveMessageTiming = (message: MessageWithParts) => { + const info = message.info as MessageInfo; + const messageId = String((info as any)?.id ?? ""); + return resolveTimingRange(messageId, info); + }; + + const resolveClusterTiming = (messageIds: string[]) => { + const infoMap = messageInfoById(); + let start: number | null = null; + let end: number | null = null; + let reason: MessageEndReason | undefined; + + for (const messageId of messageIds) { + const info = infoMap.get(messageId); + const timing = resolveTimingRange(messageId, info); + if (!timing) continue; + start = start === null ? timing.start : Math.min(start, timing.start); + end = end === null ? timing.end : Math.max(end, timing.end); + reason = pickReason(reason, timing.reason); + } + + if (start === null || end === null) return null; + const duration = end - start; + if (duration < 0) return null; + return { duration, reason }; + }; + + const formatReasonLabel = (reason: MessageEndReason) => { + switch (reason) { + case "terminated": + return "Terminated"; + case "interrupted": + return "Interrupted"; + case "error": + return "Error"; + default: + return ""; + } + }; + + const getToolStatus = (part: Part) => { + if (part.type !== "tool") return null; + const state = (part as any).state ?? {}; + return state.status as string | undefined; + }; + + const renderStepIcon = (summary: StepSummary, status: string | null | undefined) => { + const isCompleted = status === "completed"; + const isError = status === "error"; + const isRunning = status === "running"; + + // GitHub icon for git-related operations + if (summary.icon === "github") { + return ( +
+ +
+ ); + } + + // Default status icons + return ( +
+ {isCompleted ? : + isError ? : + isRunning ? : + } +
+ ); + }; + const StepsList = (listProps: { parts: Part[]; isUser: boolean }) => ( -
+
{(part) => { const summary = summarizeStep(part); + const status = getToolStatus(part); return ( -
-
- {part.type === "tool" ? : } -
-
-
{summary.title}
- -
{summary.detail}
-
- -
- -
-
-
+
+ {renderStepIcon(summary, status)} + {summary.title} + + {summary.detail} +
); }} @@ -218,6 +325,7 @@ export default function MessageList(props: MessageListProps) { if (block.kind === "steps-cluster") { const relatedStepIds = block.stepIds.filter((stepId) => stepId !== block.id); const expanded = () => isStepsExpanded(block.id, relatedStepIds); + const clusterTiming = () => resolveClusterTiming(block.messageIds); return (
@@ -244,6 +352,21 @@ export default function MessageList(props: MessageListProps) { class={`transition-transform ${expanded() ? "rotate-180" : ""}`.trim()} /> + + {(timing) => { + const reason = timing().reason; + const reasonLabel = reason && reason !== "completed" ? formatReasonLabel(reason) : null; + return ( +
+ + {formatElapsedTime(timing().duration)} + + · {reasonLabel} + +
+ ); + }} +
{ + const text = block.renderableParts + .map((part) => ("text" in part ? (part as any).text : "")) + .join("\n"); + handleCopy(text, block.messageId); + }} + > + }> + + + + ); + return (
+ {copyButton}
0}> @@ -362,22 +504,35 @@ export default function MessageList(props: MessageListProps) {
)} -
- -
+ + {(() => { + const timing = () => resolveMessageTiming(block.message); + + return ( +
+ + {(resolved) => { + const reason = resolved().reason; + const reasonLabel = reason && reason !== "completed" ? formatReasonLabel(reason) : null; + return ( +
+ + {formatElapsedTime(resolved().duration)} + + · {reasonLabel} + +
+ ); + }} +
+
+
+ {copyButton} +
+
+ ); + })()} +
); diff --git a/packages/app/src/app/context/session.ts b/packages/app/src/app/context/session.ts index 4cdfb76..33f1d4b 100644 --- a/packages/app/src/app/context/session.ts +++ b/packages/app/src/app/context/session.ts @@ -6,6 +6,8 @@ import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"; import type { Client, MessageInfo, + MessageEndReason, + MessageTiming, MessageWithParts, ModelRef, OpencodeEvent, @@ -36,6 +38,7 @@ type StoreState = { sessionStatus: Record; messages: Record; parts: Record; + messageTimings: Record; todos: Record; pendingPermissions: PendingPermission[]; events: OpencodeEvent[]; @@ -102,6 +105,35 @@ const upsertPartInfo = (list: Part[], next: Part) => { const removePartInfo = (list: Part[], partID: string) => list.filter((part) => part.id !== partID); +const resolvePartTimestamp = (part: Part) => { + const record = part as Record; + const time = record.time as { created?: unknown; updated?: unknown } | undefined; + const created = time?.created; + const updated = time?.updated; + if (typeof created === "number") return created; + if (typeof updated === "number") return updated; + return Date.now(); +}; + +const resolveEndReasonFromStatus = (status: unknown): MessageEndReason | null => { + if (!status) return null; + if (typeof status === "string") { + const normalized = status.toLowerCase(); + if (["terminated", "terminate", "killed"].includes(normalized)) return "terminated"; + if (["interrupt", "interrupted", "aborted", "cancelled", "canceled"].includes(normalized)) { + return "interrupted"; + } + if (["error", "failed", "failure"].includes(normalized)) return "error"; + return null; + } + if (typeof status === "object") { + const record = status as Record; + const type = typeof record.type === "string" ? record.type.toLowerCase() : null; + return type ? resolveEndReasonFromStatus(type) : null; + } + return null; +}; + export function createSessionStore(options: { client: () => Client | null; selectedSessionId: () => string | null; @@ -119,12 +151,14 @@ export function createSessionStore(options: { sessionStatus: {}, messages: {}, parts: {}, + messageTimings: {}, todos: {}, pendingPermissions: [], events: [], }); const [permissionReplyBusy, setPermissionReplyBusy] = createSignal(false); const reloadDetectionSet = new Set(); + const sessionEndHints = new Map(); const skillPathPattern = /[\\/]\.opencode[\\/](skill|skills)[\\/]/i; const opencodeConfigPattern = /(?:^|[\\/])opencode\.jsonc?\b/i; @@ -216,6 +250,7 @@ export function createSessionStore(options: { const sessionStatusById = () => store.sessionStatus; const pendingPermissions = () => store.pendingPermissions; const events = () => store.events; + const messageTimings = () => store.messageTimings; const selectedSession = createMemo(() => { const id = options.selectedSessionId(); @@ -229,6 +264,37 @@ export function createSessionStore(options: { return store.sessionStatus[id] ?? "idle"; }); + const finalizePendingMessageTimings = (sessionID: string, reason: MessageEndReason) => { + setStore( + produce((draft: StoreState) => { + const list = draft.messages[sessionID] ?? []; + for (const info of list) { + if ((info as any)?.role !== "assistant") continue; + const timing = draft.messageTimings[info.id]; + if (!timing || timing.endAt || !timing.startAt) continue; + const endAt = timing.lastTokenAt ?? Date.now(); + timing.endAt = endAt; + timing.endReason = reason; + draft.messageTimings[info.id] = timing; + } + }), + ); + }; + + const markSessionEndReason = (sessionID: string, reason: MessageEndReason) => { + if (!sessionID) return; + sessionEndHints.set(sessionID, reason); + }; + + const consumeSessionEndReason = (sessionID: string) => { + if (!sessionID) return null; + const reason = sessionEndHints.get(sessionID) ?? null; + if (reason) { + sessionEndHints.delete(sessionID); + } + return reason; + }; + const messages = createMemo(() => { const id = options.selectedSessionId(); if (!id) return []; @@ -461,6 +527,11 @@ export function createSessionStore(options: { const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; if (sessionID) { setStore("sessionStatus", sessionID, normalizeSessionStatus(record.status)); + const endReason = resolveEndReasonFromStatus(record.status); + if (endReason) { + consumeSessionEndReason(sessionID); + finalizePendingMessageTimings(sessionID, endReason); + } } } } @@ -471,6 +542,8 @@ export function createSessionStore(options: { const sessionID = typeof record.sessionID === "string" ? record.sessionID : null; if (sessionID) { setStore("sessionStatus", sessionID, "idle"); + const hintedReason = consumeSessionEndReason(sessionID); + finalizePendingMessageTimings(sessionID, hintedReason ?? "completed"); } } } @@ -496,6 +569,14 @@ export function createSessionStore(options: { } setStore("messages", info.sessionID, (current = []) => upsertMessageInfo(current, info)); + const completed = (info as any)?.time?.completed; + if (typeof completed === "number") { + setStore("messageTimings", info.id, (current: MessageTiming = {}) => ({ + ...current, + endAt: completed, + endReason: "completed" as MessageEndReason, + })); + } } } } @@ -508,6 +589,11 @@ export function createSessionStore(options: { if (sessionID && messageID) { setStore("messages", sessionID, (current = []) => removeMessageInfo(current, messageID)); setStore("parts", messageID, []); + setStore("messageTimings", (current) => { + const next = { ...current }; + delete next[messageID]; + return next; + }); } } } @@ -518,6 +604,7 @@ export function createSessionStore(options: { if (record.part && typeof record.part === "object") { const part = record.part as Part; const delta = typeof record.delta === "string" ? record.delta : null; + const partTime = resolvePartTimestamp(part); setStore( produce((draft: StoreState) => { @@ -540,6 +627,23 @@ export function createSessionStore(options: { } draft.parts[part.messageID] = upsertPartInfo(parts, part); + + const timing = draft.messageTimings[part.messageID] ?? {}; + if (!timing.startAt || partTime < timing.startAt) { + timing.startAt = partTime; + } + if (!timing.lastTokenAt || partTime > timing.lastTokenAt) { + timing.lastTokenAt = partTime; + } + if ( + timing.endAt && + timing.endReason && + timing.endReason !== "completed" && + partTime > timing.endAt + ) { + timing.endAt = partTime; + } + draft.messageTimings[part.messageID] = timing; }), ); maybeMarkReloadRequired(part); @@ -683,6 +787,7 @@ export function createSessionStore(options: { selectedSession, selectedSessionStatus, messages, + messageTimings, todos, pendingPermissions, permissionReplyBusy, @@ -693,6 +798,7 @@ export function createSessionStore(options: { selectSession, renameSession, respondPermission, + markSessionEndReason, setSessions, setSessionStatusById, setMessages, diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index 5c2a063..554ed16 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -7,6 +7,7 @@ import type { CommandRegistryItem, CommandTriggerContext, MessageGroup, + MessageTiming, MessageWithParts, PendingPermission, SkillCard, @@ -35,6 +36,7 @@ import Composer from "../components/session/composer"; import SessionSidebar, { type SidebarSectionState } from "../components/session/sidebar"; import ContextPanel from "../components/session/context-panel"; import FlyoutItem from "../components/flyout-item"; +import { formatElapsedTime } from "../utils"; export type SessionViewProps = { selectedSessionId: string | null; @@ -51,6 +53,7 @@ export type SessionViewProps = { sessions: Array<{ id: string; title: string; slug?: string | null }>; selectSession: (sessionId: string) => Promise | void; messages: MessageWithParts[]; + messageTimings: Record; todos: TodoItem[]; busyLabel: string | null; developerMode: boolean; @@ -73,6 +76,7 @@ export type SessionViewProps = { busy: boolean; prompt: string; setPrompt: (value: string) => void; + cancelRun: () => Promise; selectedSessionModelLabel: string; openSessionModelPicker: () => void; modelVariantLabel: string; @@ -360,7 +364,7 @@ export default function SessionView(props: SessionViewProps) { return Math.max(0, runTick() - start); }); - const runElapsedLabel = createMemo(() => `${Math.round(runElapsedMs()).toLocaleString()}ms`); + const runElapsedLabel = createMemo(() => formatElapsedTime(runElapsedMs())); onMount(() => { setTimeout(() => setIsInitialLoad(false), 2000); @@ -402,23 +406,15 @@ export default function SessionView(props: SessionViewProps) { } }); - createEffect( - on( - () => [ - props.messages.length, - props.todos.length, - props.messages.reduce((acc, m) => acc + m.parts.length, 0), - ], - (current, previous) => { - if (!previous) return; - const [mLen, tLen, pCount] = current; - const [prevM, prevT, prevP] = previous; - if (mLen > prevM || tLen > prevT || pCount > prevP) { - messagesEndEl?.scrollIntoView({ behavior: "smooth" }); - } - }, - ), - ); + const [prevMessageCount, setPrevMessageCount] = createSignal(0); + createEffect(() => { + const currentCount = props.messages.length; + const prev = prevMessageCount(); + if (currentCount > prev) { + messagesEndEl?.scrollIntoView({ behavior: "smooth" }); + } + setPrevMessageCount(currentCount); + }); const triggerFlyout = ( sourceEl: Element | null, @@ -1047,6 +1043,7 @@ export default function SessionView(props: SessionViewProps) { props.cancelRun()} onDraftChange={handleDraftChange} commandMatches={commandMatches()} onRunCommand={handleRunCommand} diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts index eee0ecd..5ed115c 100644 --- a/packages/app/src/app/types.ts +++ b/packages/app/src/app/types.ts @@ -40,6 +40,15 @@ export type MessageWithParts = { parts: Part[]; }; +export type MessageEndReason = "completed" | "interrupted" | "terminated" | "error"; + +export type MessageTiming = { + startAt?: number; + lastTokenAt?: number; + endAt?: number; + endReason?: MessageEndReason; +}; + export type MessageGroup = | { kind: "text"; part: Part } | { kind: "steps"; id: string; parts: Part[] }; diff --git a/packages/app/src/app/utils/index.ts b/packages/app/src/app/utils/index.ts index f685202..0b7e205 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -227,6 +227,25 @@ export function formatRelativeTime(timestampMs: number) { return new Date(timestampMs).toLocaleDateString(); } +export function formatElapsedTime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; + } + if (minutes > 0) { + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + if (seconds > 0) { + return `${seconds}s`; + } + return `${ms}ms`; +} + export function commandPathFromWorkspaceRoot(workspaceRoot: string, commandName: string) { const root = workspaceRoot.trim().replace(/\/+$/, ""); const name = commandName.trim().replace(/^\/+/, ""); @@ -379,11 +398,27 @@ export function removePart(list: MessageWithParts[], messageID: string, partID: } export function normalizeSessionStatus(status: unknown) { - if (!status || typeof status !== "object") return "idle"; - const record = status as Record; - if (record.type === "busy") return "running"; - if (record.type === "retry") return "retry"; - if (record.type === "idle") return "idle"; + const resolveType = (value: unknown) => { + if (!value) return null; + if (typeof value === "string") return value.toLowerCase(); + if (typeof value === "object") { + const record = value as Record; + if (typeof record.type === "string") return record.type.toLowerCase(); + } + return null; + }; + + const type = resolveType(status); + if (!type) return "idle"; + + if (type === "busy" || type === "running") return "running"; + if (type === "retry") return "retry"; + if (type === "idle") return "idle"; + + if (["terminated", "terminate", "killed"].includes(type)) return "terminated"; + if (["interrupt", "interrupted", "aborted", "cancelled", "canceled"].includes(type)) return "interrupted"; + if (["error", "failed", "failure"].includes(type)) return "error"; + return "idle"; } @@ -411,7 +446,8 @@ export function lastUserModelFromMessages(list: MessageWithParts[]): ModelRef | } export function isStepPart(part: Part) { - return part.type === "reasoning" || part.type === "tool" || part.type === "step-start" || part.type === "step-finish"; + // Only count reasoning and tool as real steps, ignore step-start/step-finish markers + return part.type === "reasoning" || part.type === "tool"; } export function groupMessageParts(parts: Part[], messageId: string): MessageGroup[] { @@ -462,34 +498,203 @@ export function groupMessageParts(parts: Part[], messageId: string): MessageGrou return groups; } -export function summarizeStep(part: Part): { title: string; detail?: string } { +const TOOL_LABELS: Record = { + bash: "Bash", + read: "Read", + write: "Write", + edit: "Edit", + patch: "Patch", + multiedit: "MultiEdit", + grep: "Grep", + glob: "Glob", + task: "Task", + webfetch: "Fetch", + fetchurl: "Fetch", + websearch: "Search", + execute: "Execute", + create: "Create", + ls: "List", + skill: "Skill", + todowrite: "Todo", +}; + +// Tools that should show GitHub icon (git operations) +const GITHUB_TOOLS = new Set([ + "git", "gh", "github", "mcp_github", "mcp-github", + "git_status", "git_diff", "git_log", "git_commit", "git_push", "git_pull", + "create_pull_request", "list_pull_requests", "get_pull_request", + "create_issue", "list_issues", "get_issue", + "create_branch", "list_branches", "create_repository", +]); + +// Shorten path to last N segments +function shortenPath(path: string, segments = 3): string { + const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); + if (parts.length <= segments) return path; + return parts.slice(-segments).join("/"); +} + +// Format file size or line count +function formatReadInfo(input: Record): string | null { + const parts: string[] = []; + + // Get file path (shortened) + const filePath = input.file_path ?? input.path; + if (typeof filePath === "string" && filePath.trim()) { + parts.push(shortenPath(filePath.trim())); + } + + // Add line range info if present + const offset = input.offset ?? input.start_line; + const limit = input.limit ?? input.end_line ?? input.lines; + if (typeof offset === "number" || typeof limit === "number") { + const rangeInfo: string[] = []; + if (typeof offset === "number" && offset > 0) rangeInfo.push(`from L${offset}`); + if (typeof limit === "number") rangeInfo.push(`${limit} lines`); + if (rangeInfo.length) parts.push(`(${rangeInfo.join(", ")})`); + } + + return parts.length ? parts.join(" ") : null; +} + +// Format list directory info +function formatListInfo(input: Record): string | null { + const dirPath = input.directory_path ?? input.path ?? input.folder; + if (typeof dirPath === "string" && dirPath.trim()) { + return shortenPath(dirPath.trim()); + } + return null; +} + +// Format search info (grep/glob) +function formatSearchInfo(toolName: string, input: Record): string | null { + const parts: string[] = []; + + // Pattern + const pattern = input.pattern ?? input.query ?? input.patterns; + if (typeof pattern === "string" && pattern.trim()) { + const p = pattern.trim(); + parts.push(p.length > 30 ? `"${p.slice(0, 30)}…"` : `"${p}"`); + } else if (Array.isArray(pattern) && pattern.length > 0) { + const first = String(pattern[0]); + parts.push(first.length > 30 ? `"${first.slice(0, 30)}…"` : `"${first}"`); + } + + // Path context + const path = input.path ?? input.folder ?? input.directory; + if (typeof path === "string" && path.trim()) { + parts.push(`in ${shortenPath(path.trim(), 2)}`); + } + + // File type filter + const fileType = input.type ?? input.glob_pattern; + if (typeof fileType === "string" && fileType.trim()) { + parts.push(`(${fileType})`); + } + + return parts.length ? parts.join(" ") : null; +} + +// Format command/execute info +function formatCommandInfo(input: Record): string | null { + const cmd = input.command ?? input.cmd; + if (typeof cmd === "string" && cmd.trim()) { + const trimmed = cmd.trim(); + // Show first line only, truncate if too long + const firstLine = trimmed.split("\n")[0]; + return firstLine.length > 50 ? `${firstLine.slice(0, 50)}…` : firstLine; + } + return null; +} + +export type StepSummary = { + title: string; + detail?: string; + icon?: "github" | "default"; +}; + +export function summarizeStep(part: Part): StepSummary { if (part.type === "tool") { const record = part as any; const toolName = record.tool ? String(record.tool) : "Tool"; + const toolLower = toolName.toLowerCase(); + const label = TOOL_LABELS[toolLower] ?? toolName; const state = record.state ?? {}; - const title = state.title ? String(state.title) : toolName; - const output = typeof state.output === "string" && state.output.trim() ? state.output.trim() : null; - if (output) { - const short = output.length > 160 ? `${output.slice(0, 160)}…` : output; - return { title, detail: short }; + const input = typeof state.input === "object" && state.input ? state.input : {}; + + // Determine if this is a GitHub-related tool + const isGithubTool = GITHUB_TOOLS.has(toolLower) || + toolLower.includes("github") || + toolLower.includes("git_") || + toolLower.startsWith("gh_") || + (toolLower === "execute" && typeof input.command === "string" && + (input.command.startsWith("git ") || input.command.startsWith("gh "))); + + // Some tools don't need detail + const noDetailTools = ["todowrite"]; + if (noDetailTools.includes(toolLower)) { + return { title: label, icon: isGithubTool ? "github" : "default" }; + } + + // Extract detail based on tool type + let detail: string | null = null; + + // Read file + if (toolLower === "read") { + detail = formatReadInfo(input); + } + // List directory + else if (toolLower === "ls" || toolLower === "list") { + detail = formatListInfo(input); + } + // Search (grep/glob) + else if (["grep", "glob", "find"].includes(toolLower)) { + detail = formatSearchInfo(toolLower, input); } - return { title }; + // Command/Execute + else if (["bash", "execute", "shell"].includes(toolLower)) { + detail = formatCommandInfo(input); + } + // Edit/Write/Create - show file path + else if (["edit", "write", "create", "patch", "multiedit"].includes(toolLower)) { + const filePath = input.file_path ?? input.path; + if (typeof filePath === "string" && filePath.trim()) { + detail = shortenPath(filePath.trim()); + } + } + // Fetch/WebSearch - show URL or query + else if (["webfetch", "fetchurl", "websearch"].includes(toolLower)) { + const url = input.url; + const query = input.query; + if (typeof url === "string" && url.trim()) { + const u = url.trim(); + detail = u.length > 50 ? `${u.slice(0, 50)}…` : u; + } else if (typeof query === "string" && query.trim()) { + const q = query.trim(); + detail = q.length > 40 ? `"${q.slice(0, 40)}…"` : `"${q}"`; + } + } + + // Fallback to state.title if no detail extracted + if (!detail && state.title) { + const titleStr = typeof state.title === "string" + ? state.title + : typeof state.title === "object" + ? JSON.stringify(state.title).slice(0, 80) + : String(state.title); + const title = titleStr.trim(); + detail = title.length > 60 ? `${title.slice(0, 60)}…` : title; + } + + return { + title: label, + detail: detail ?? undefined, + icon: isGithubTool ? "github" : "default" + }; } if (part.type === "reasoning") { - const record = part as any; - const text = typeof record.text === "string" ? record.text.trim() : ""; - if (!text) return { title: "Planning" }; - const short = text.length > 120 ? `${text.slice(0, 120)}…` : text; - return { title: "Thinking", detail: short }; - } - - if (part.type === "step-start" || part.type === "step-finish") { - const reason = (part as any).reason; - return { - title: part.type === "step-start" ? "Step started" : "Step finished", - detail: reason ? String(reason) : undefined, - }; + return { title: "Thinking" }; } return { title: "Step" };