diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 52f5bfc5..a41b06ed 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -26,6 +26,11 @@ jobs: with: version: 10.27.0 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.6" + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 00c3f22c..a72cf013 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 02390e6a..ff3e836c 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -617,6 +617,7 @@ export default function App() { selectedSession, selectedSessionStatus, messages, + messageTimings, todos, pendingPermissions, permissionReplyBusy, @@ -627,6 +628,7 @@ export default function App() { selectSession, renameSession, respondPermission, + markSessionEndReason, setSessions, setSessionStatusById, setMessages, @@ -808,6 +810,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) { @@ -4168,6 +4187,7 @@ export default function App() { })), selectSession: selectSession, messages: activeMessages(), + messageTimings: messageTimings(), todos: activeTodos(), busyLabel: busyLabel(), developerMode: developerMode(), @@ -4184,6 +4204,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 bb39c4ec..36ce8015 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, onMount } 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; @@ -1162,18 +1163,31 @@ 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 a26a5771..2c764b67 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, Sparkles } from "lucide-solid"; +import { Check, ChevronDown, Circle, Clock, Copy, File, Github, Sparkles } 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; @@ -183,32 +184,159 @@ 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"; + + if (summary.isSkill) { + const colorClass = isError + ? "text-red-10" + : isCompleted + ? "text-green-10" + : "text-purple-10"; + return ( +
+ +
+ ); + } + + // 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 ( -
-
- {summary.isSkill ? : part.type === "tool" ? : } -
-
-
- {summary.title} +
+ {renderStepIcon(summary, status)} +
+
+ {summary.title} skill + + {summary.detail} +
- -
{summary.detail}
-
stepId !== block.id); const expanded = () => isStepsExpanded(block.id, relatedStepIds); + const clusterTiming = () => resolveClusterTiming(block.messageIds); return (
@@ -260,6 +389,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}> @@ -378,22 +541,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 8f4d5e1d..b6625abe 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, @@ -37,6 +39,7 @@ type StoreState = { sessionStatus: Record; messages: Record; parts: Record; + messageTimings: Record; todos: Record; pendingPermissions: PendingPermission[]; events: OpencodeEvent[]; @@ -103,6 +106,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; @@ -120,12 +152,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 skillNamePattern = /[\\/]\.opencode[\\/](?:skill|skills)[\\/]+([^\\/]+)/i; @@ -263,6 +297,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(); @@ -276,6 +311,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 []; @@ -508,6 +574,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); + } } } } @@ -518,6 +589,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"); } } } @@ -584,6 +657,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, + })); + } } } } @@ -596,6 +677,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; + }); } } } @@ -606,6 +692,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) => { @@ -628,6 +715,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); @@ -803,6 +907,7 @@ export function createSessionStore(options: { selectedSession, selectedSessionStatus, messages, + messageTimings, todos, pendingPermissions, permissionReplyBusy, @@ -813,6 +918,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 a3aab7ff..de7572b7 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, McpServerEntry, McpStatusMap, @@ -38,6 +39,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; @@ -60,6 +62,7 @@ export type SessionViewProps = { sessions: Array<{ id: string; title: string; slug?: string | null; workspaceLabel?: string | null }>; selectSession: (sessionId: string) => Promise | void; messages: MessageWithParts[]; + messageTimings: Record; todos: TodoItem[]; busyLabel: string | null; developerMode: boolean; @@ -85,6 +88,7 @@ export type SessionViewProps = { busy: boolean; prompt: string; setPrompt: (value: string) => void; + cancelRun: () => Promise; selectedSessionModelLabel: string; openSessionModelPicker: () => void; modelVariantLabel: string; @@ -451,7 +455,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); @@ -502,29 +506,21 @@ 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) { - const shouldScroll = scrollOnNextUpdate() || autoScrollEnabled(); - if (shouldScroll) { - scrollToLatest(scrollOnNextUpdate() ? "smooth" : "auto"); - } - if (scrollOnNextUpdate()) { - setScrollOnNextUpdate(false); - } - } - }, - ), - ); + const [prevMessageCount, setPrevMessageCount] = createSignal(0); + createEffect(() => { + const currentCount = props.messages.length; + const prev = prevMessageCount(); + if (currentCount > prev) { + const shouldScroll = scrollOnNextUpdate() || autoScrollEnabled(); + if (shouldScroll) { + scrollToLatest(scrollOnNextUpdate() ? "smooth" : "auto"); + } + if (scrollOnNextUpdate()) { + setScrollOnNextUpdate(false); + } + } + setPrevMessageCount(currentCount); + }); const triggerFlyout = ( sourceEl: Element | null, @@ -1235,6 +1231,7 @@ export default function SessionView(props: SessionViewProps) { props.cancelRun()} onDraftChange={handleDraftChange} commandMatches={commandMatches()} onRunCommand={handleRunCommand} diff --git a/packages/app/src/app/pages/settings.tsx b/packages/app/src/app/pages/settings.tsx index 4c230f99..0aa6ef74 100644 --- a/packages/app/src/app/pages/settings.tsx +++ b/packages/app/src/app/pages/settings.tsx @@ -282,7 +282,8 @@ function OwpenbotSettings(props: { setOwpenbotStatus(latestStatus); } const serverClient = openworkServerClient(); - const useRemote = Boolean(serverClient && props.openworkServerWorkspaceId); + const workspaceId = props.openworkServerWorkspaceId; + const useRemote = Boolean(serverClient && workspaceId); debugOwpenbot("save-token:start", { mode: props.mode ?? "unknown", tauri: isTauriRuntime(), @@ -299,6 +300,7 @@ function OwpenbotSettings(props: { ), }); if (useRemote) { + if (!serverClient || !workspaceId) return; if (props.openworkServerStatus === "disconnected") { setTelegramFeedback( "error", @@ -316,7 +318,7 @@ function OwpenbotSettings(props: { setTelegramFeedback("checking", "Saving token on the host..."); try { await serverClient.setOwpenbotTelegramToken( - props.openworkServerWorkspaceId, + workspaceId, token, latestStatus?.healthPort ?? owpenbotStatus()?.healthPort ?? null, ); diff --git a/packages/app/src/app/types.ts b/packages/app/src/app/types.ts index 5a125ea7..09acae42 100644 --- a/packages/app/src/app/types.ts +++ b/packages/app/src/app/types.ts @@ -48,6 +48,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 286e2651..fca16589 100644 --- a/packages/app/src/app/utils/index.ts +++ b/packages/app/src/app/utils/index.ts @@ -237,6 +237,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(/^\/+/, ""); @@ -389,11 +408,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"; } @@ -421,7 +456,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[] { @@ -466,44 +502,226 @@ export function groupMessageParts(parts: Part[], messageId: string): MessageGrou return groups; } -export function summarizeStep(part: Part): { title: string; detail?: string; isSkill?: boolean; skillName?: 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", + list: "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"; + isSkill?: boolean; + skillName?: string; +}; + +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; - - // Detect skill trigger - if (toolName === "skill") { - const skillName = state.metadata?.name || title.replace(/^Loaded skill:\s*/i, ""); - if (output) { - const short = output.length > 160 ? `${output.slice(0, 160)}…` : output; - return { title, isSkill: true, skillName, detail: short }; + const input = typeof state.input === "object" && state.input ? state.input : {}; + + 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 "))); + + if (toolLower === "skill") { + const title = state.title ? String(state.title) : toolName; + const rawName = state.metadata?.name ?? title.replace(/^Loaded skill:\s*/i, ""); + const skillName = typeof rawName === "string" ? rawName.trim() : ""; + return { + title: label, + detail: skillName || undefined, + icon: "default", + isSkill: true, + skillName: skillName || undefined, + }; + } + + if (toolLower === "todowrite") { + return { title: label, icon: isGithubTool ? "github" : "default" }; + } + + let detail: string | null = null; + + if (toolLower === "read") { + detail = formatReadInfo(input); + } else if (toolLower === "ls" || toolLower === "list") { + detail = formatListInfo(input); + } else if (["grep", "glob", "find"].includes(toolLower)) { + detail = formatSearchInfo(toolLower, input); + } else if (["bash", "execute", "shell"].includes(toolLower)) { + detail = formatCommandInfo(input); + } 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()); + } + } 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}"`; } - return { title, isSkill: true, skillName }; } - - if (output) { - const short = output.length > 160 ? `${output.slice(0, 160)}…` : output; - return { title, detail: short }; + + 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 }; + + 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 }; + return { title: "Thinking" }; } 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, }; } diff --git a/packages/desktop/src-tauri/src/engine/doctor.rs b/packages/desktop/src-tauri/src/engine/doctor.rs index 433e3b6c..4a40e7a5 100644 --- a/packages/desktop/src-tauri/src/engine/doctor.rs +++ b/packages/desktop/src-tauri/src/engine/doctor.rs @@ -2,9 +2,8 @@ use std::ffi::OsStr; use std::path::Path; use crate::engine::paths::{ - resolve_opencode_env_override, - resolve_opencode_executable, - resolve_opencode_executable_without_override, + resolve_opencode_env_override, resolve_opencode_executable, + resolve_opencode_executable_without_override, }; use crate::platform::command_for_program; use crate::utils::truncate_output; @@ -144,6 +143,12 @@ mod tests { std::env::set_var(key, value); Self { key, original } } + + fn unset(key: &'static str) -> Self { + let original = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, original } + } } impl Drop for EnvVarGuard { @@ -194,6 +199,8 @@ mod tests { #[test] #[cfg(not(windows))] fn resolve_engine_path_prefers_sidecar() { + let _lock = ENV_LOCK.lock().expect("lock env"); + let _guard = EnvVarGuard::unset("OPENCODE_BIN_PATH"); let dir = unique_temp_dir("engine-path-test"); std::fs::create_dir_all(&dir).expect("create temp dir"); @@ -228,7 +235,9 @@ mod tests { let (resolved, _in_path, notes) = resolve_engine_path(true, None, Some(sidecar_dir.as_path())); assert_eq!(resolved.as_ref(), Some(&override_path)); - assert!(notes.iter().any(|note| note.contains("Using OPENCODE_BIN_PATH"))); + assert!(notes + .iter() + .any(|note| note.contains("Using OPENCODE_BIN_PATH"))); let _ = std::fs::remove_dir_all(&override_dir); let _ = std::fs::remove_dir_all(&sidecar_dir);