diff --git a/CONTEXT.md b/CONTEXT.md index fb0f447..7960df9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -398,6 +398,18 @@ _Avoid_: default to Discover A backend-owned external capability exposed through MCP. Tools are managed separately from Plugins even though both can extend agent behaviour. _Avoid_: Plugin, frontend tool +**Tool call**: +A single visible use of a Tool within a Session transcript or Live Session stream. A Tool call has a tool name, input, output, and status (`running`, `success`, or `error`); protocol states such as pending are implementation details and present as running. Tool calls are passive execution metadata attached to an assistant turn, not chat-equivalent assistant content and not user-actionable prompts. +_Avoid_: plugin call, raw log line, assistant message, permission request, question prompt + +**Tool call presentation**: +The compact UI rendering of a Tool call. Collapsed Tool call rows show a status icon plus a semantic label for known tools, or a prettified tool name for unknown/custom MCP tools. Inputs are hidden for now. Outputs, including error text, terminal text, structured output, and images, are shown only when the Tool call is expanded. A Tool call is expandable only when it has meaningful output; empty, whitespace-only, or structurally useless output must not create an expansion affordance or blank body. Long expanded output is bounded and scrollable. +_Avoid_: always show input, JSON inspector by default, empty expandable row, inline error by default + +**Interaction request**: +A user-actionable, agent-blocking request inside a Session where the agent is paused until the user decides something. Permission requests and question prompts are Interaction requests, not ordinary Tool calls. They should render as prominent actionable panels rather than passive collapsible Tool call rows. +_Avoid_: tool output, permission tool call, question tool call, passive transcript metadata + **Harness restart**: A user-requested recovery action that hard-restarts all Harness processes managed by OpenGUI, not just the currently selected Harness. It stops background agent processes such as the Pi daemon before starting them again. _Avoid_: server restart, selected backend restart, soft reconnect diff --git a/src/agents/protocol/opencode-map.test.ts b/src/agents/protocol/opencode-map.test.ts index a569a8a..c60a1b8 100644 --- a/src/agents/protocol/opencode-map.test.ts +++ b/src/agents/protocol/opencode-map.test.ts @@ -30,6 +30,28 @@ describe("mapOpenCodeEvent", () => { }); }); + test("ignores malformed session lifecycle events without crashing SSE handling", () => { + expect( + mapOpenCodeEvent( + { + type: "session.updated", + properties: { info: { title: "Missing ID" } }, + } as unknown as OpenCodeEvent, + context, + ), + ).toBeNull(); + + expect( + mapOpenCodeEvent( + { + type: "session.created", + properties: {}, + } as unknown as OpenCodeEvent, + context, + ), + ).toBeNull(); + }); + test("normalizes message IDs", () => { const message = { id: "message-1", sessionID: "session-1" } as unknown as Message; diff --git a/src/agents/protocol/opencode-map.ts b/src/agents/protocol/opencode-map.ts index 2fcd30e..5379999 100644 --- a/src/agents/protocol/opencode-map.ts +++ b/src/agents/protocol/opencode-map.ts @@ -32,6 +32,14 @@ type OpenCodeEventHandler = ( type EventProperties = Record; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isTaggedSession(value: unknown): value is TaggedSession { + return isRecord(value) && typeof value.id === "string" && value.id.length > 0; +} + function getProperties(event: OpenCodeEvent): EventProperties { return event.properties as EventProperties; } @@ -59,6 +67,7 @@ function normalizeOpenCodePayload(raw: OpenCodeEvent | OpenCodeSyncEnvelope): Op export const openCodeEventHandlers: Partial> = { "session.created": (event, context) => { const { info } = getProperties(event) as { info: TaggedSession }; + if (!isTaggedSession(info)) return null; return { type: "session.created", directory: context.directory, @@ -68,6 +77,7 @@ export const openCodeEventHandlers: Partial }, "session.updated": (event, context) => { const { info } = getProperties(event) as { info: TaggedSession }; + if (!isTaggedSession(info)) return null; return { type: "session.updated", directory: context.directory, @@ -77,6 +87,7 @@ export const openCodeEventHandlers: Partial }, "session.deleted": (event, context) => { const { info } = getProperties(event) as { info: { id: string } }; + if (!isTaggedSession(info)) return null; return { type: "session.deleted", directory: context.directory, diff --git a/src/agents/shared.ts b/src/agents/shared.ts index faa0010..44c3c5c 100644 --- a/src/agents/shared.ts +++ b/src/agents/shared.ts @@ -39,7 +39,13 @@ export function tagBackendSession( session: TaggedSession, target?: { directory?: string; workspaceId?: string }, ): TaggedSession { - const rawId = session._rawId ?? session.id; + const rawId = + typeof session._rawId === "string" + ? session._rawId + : typeof session.id === "string" + ? session.id + : null; + if (!rawId) return session; const projectDir = session.directory ?? session._projectDir ?? target?.directory; return { ...session, diff --git a/src/components/DialogSelectProvider.tsx b/src/components/DialogSelectProvider.tsx index bbcc231..5886f26 100644 --- a/src/components/DialogSelectProvider.tsx +++ b/src/components/DialogSelectProvider.tsx @@ -5,7 +5,6 @@ * Clicking a provider fires onSelect; clicking "Custom" fires onCustom. */ -import type { Provider } from "@opencode-ai/sdk/v2/client"; import { Check, Plus, Search } from "lucide-react"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -13,6 +12,7 @@ import { ProviderIcon } from "@/components/provider-icons/ProviderIcon"; import { SubDialogHeader } from "@/components/SubDialogHeader"; import { Input } from "@/components/ui/input"; import { POPULAR_PROVIDER_IDS } from "@/lib/constants"; +import type { ProviderResource } from "@/protocol/harness-resources"; // --------------------------------------------------------------------------- // Constants @@ -25,7 +25,7 @@ const POPULAR_IDS = new Set(POPULAR_PROVIDER_IDS); // --------------------------------------------------------------------------- interface DialogSelectProviderProps { - providers: Provider[]; + providers: ProviderResource[]; connectedIds: Set; onSelect: (providerID: string) => void; onCustom: () => void; @@ -48,8 +48,8 @@ export function DialogSelectProvider({ const lowerSearch = search.toLowerCase().trim(); const { popular, other } = useMemo(() => { - const pop: Provider[] = []; - const oth: Provider[] = []; + const pop: ProviderResource[] = []; + const oth: ProviderResource[] = []; for (const p of providers) { // Filter by search if ( @@ -159,7 +159,7 @@ function ProviderRow({ connected, onSelect, }: { - provider: Provider; + provider: ProviderResource; connected: boolean; onSelect: (id: string) => void; }) { diff --git a/src/components/McpDialog.tsx b/src/components/McpDialog.tsx index 7a55629..769eb0d 100644 --- a/src/components/McpDialog.tsx +++ b/src/components/McpDialog.tsx @@ -5,7 +5,6 @@ * No add, edit, or delete - purely runtime toggling. */ -import type { McpStatus } from "@opencode-ai/sdk/v2/client"; import { AlertCircle, CheckCircle2, Globe, Terminal } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -16,6 +15,7 @@ import { Switch } from "@/components/ui/switch"; import { useHarness } from "@/hooks/use-agent-backend"; import { useConnectionState } from "@/hooks/use-agent-state"; import { MCP_TOGGLE_DELAY_MS } from "@/lib/constants"; +import type { McpServerStatus } from "@/protocol/harness-resources"; // --------------------------------------------------------------------------- // Status badge @@ -46,7 +46,7 @@ const STATUS_CONFIG = { }, } as const; -function StatusBadge({ status }: { status: McpStatus }) { +function StatusBadge({ status }: { status: McpServerStatus }) { const { t } = useTranslation(); const config = STATUS_CONFIG[status.status as keyof typeof STATUS_CONFIG] ?? @@ -79,7 +79,7 @@ export function McpDialog({ open, onOpenChange }: McpDialogProps) { const configApi = backend?.platform?.config; const { activeDirectory, activeWorkspaceId } = useConnectionState(); - const [mcpStatus, setMcpStatus] = useState>({}); + const [mcpStatus, setMcpStatus] = useState>({}); const [mcpTypes, setMcpTypes] = useState>({}); const [loading, setLoading] = useState(true); const [toggling, setToggling] = useState(null); @@ -114,7 +114,7 @@ export function McpDialog({ open, onOpenChange }: McpDialogProps) { } }, [open, refresh]); - const handleToggle = async (name: string, currentStatus: McpStatus) => { + const handleToggle = async (name: string, currentStatus: McpServerStatus) => { if (!mcpApi) return; setToggling(name); try { diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index d0e816b..9e68e81 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -3,18 +3,16 @@ * Handles user messages, assistant text, tool calls, and permission requests. */ -import type { Part, TextPart } from "@opencode-ai/sdk/v2/client"; -import { ShieldAlert, Undo2 } from "lucide-react"; +import { Undo2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { QuestionPanel } from "@/components/message-list/QuestionPanel"; +import { InteractionRequestsView } from "@/components/message-list/interactions/InteractionRequestsView"; import { MessageBubble } from "@/components/message-list/MessageBubble"; import { type ScrollSnapshot, VirtualMessageScroller, } from "@/components/message-list/VirtualMessageScroller"; import type { TurnFooter } from "@/components/message-list/types"; -import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { useBackendCapabilities } from "@/hooks/use-agent-backend"; import { @@ -24,13 +22,14 @@ import { useSessionState, } from "@/hooks/use-agent-state"; import type { TurnRun } from "@/hooks/agent-state-types"; +import type { TextTranscriptPart, TranscriptPart } from "@/protocol/session-transcript"; import logoDark from "../../opengui-dark.svg"; import logoLight from "../../opengui-light.svg"; /** Part types that actually render something visible. */ const RENDERABLE_TYPES = new Set(["text", "reasoning", "tool", "file"]); /** Check if a part will produce visible output. */ -function isRenderablePart(part: Part): boolean { +function isRenderablePart(part: TranscriptPart): boolean { if (!RENDERABLE_TYPES.has(part.type)) return false; // text parts with empty content render nothing if (part.type === "text" && !part.text?.trim()) return false; @@ -52,9 +51,9 @@ function nonEmptyString(value: unknown): string | undefined { } function getEntryText(entry: MessageEntry): string { - return (entry.parts as TextPart[]) + return entry.parts .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => part.text) + .map((part) => (part as TextTranscriptPart).text) .join("\n") .trim(); } @@ -105,7 +104,6 @@ function RevertBanner({ } export function MessageList({ detachedProject: _detachedProject }: { detachedProject?: string }) { - const { t } = useTranslation(); const { respondPermission, replyQuestion, @@ -136,7 +134,7 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro const pendingQuestion = activeSessionId ? (pendingQuestions[activeSessionId] ?? null) : null; const [expandedUserMessages, setExpandedUserMessages] = useState>(() => new Set()); - const [expandedToolParts, setExpandedToolParts] = useState>(() => new Set()); + const [expandedToolCalls, setExpandedToolCalls] = useState>(() => new Set()); const scrollSnapshotsRef = useRef(new Map()); useEffect(() => { @@ -322,8 +320,8 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro }); }, []); - const toggleToolPart = useCallback((partId: string, expanded: boolean) => { - setExpandedToolParts((current) => { + const toggleToolCall = useCallback((partId: string, expanded: boolean) => { + setExpandedToolCalls((current) => { const next = new Set(current); if (expanded) next.add(partId); else next.delete(partId); @@ -356,9 +354,9 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro : undefined } expandedUserMessages={expandedUserMessages} - expandedToolParts={expandedToolParts} + expandedToolCalls={expandedToolCalls} onToggleUserMessage={toggleUserMessage} - onToggleToolPart={toggleToolPart} + onToggleToolCall={toggleToolCall} /> ); @@ -371,9 +369,9 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro forkFromMessage, revertToMessage, expandedUserMessages, - expandedToolParts, + expandedToolCalls, toggleUserMessage, - toggleToolPart, + toggleToolCall, capabilities, ], ); @@ -415,42 +413,13 @@ export function MessageList({ detachedProject: _detachedProject }: { detachedPro )} - {capabilities?.permissions && pendingPermission && ( -
-
- -
-

- {t("permissionPanel.title", { permission: pendingPermission.permission })} -

- {pendingPermission.patterns.length > 0 && ( -

- {pendingPermission.patterns.join(", ")} -

- )} -
-
-
- - - -
-
- )} - - {capabilities?.questions && pendingQuestion && ( - replyQuestion(answers)} - onDismiss={() => rejectQuestion()} - /> - )} + ); diff --git a/src/components/SlashCommandPopover.tsx b/src/components/SlashCommandPopover.tsx index 704a459..b3573ff 100644 --- a/src/components/SlashCommandPopover.tsx +++ b/src/components/SlashCommandPopover.tsx @@ -3,18 +3,18 @@ * Shows available slash commands filtered by the current input. */ -import type { Command } from "@opencode-ai/sdk/v2/client"; import { cn } from "@/lib/utils"; +import type { SlashCommandResource } from "@/protocol/harness-resources"; -interface SlashCommandPopoverProps { - commands: Command[]; +interface SlashCommandPopoverProps { + commands: TCommand[]; filter: string; activeIndex: number; - onSelect: (command: Command) => void; + onSelect: (command: TCommand) => void; onHover: (index: number) => void; } -function matchesFilter(command: Command, filter: string): boolean { +function matchesFilter(command: SlashCommandResource, filter: string): boolean { if (!filter) return true; const lower = filter.toLowerCase(); if (command.name.toLowerCase().includes(lower)) return true; @@ -22,17 +22,20 @@ function matchesFilter(command: Command, filter: string): boolean { return false; } -export function useFilteredCommands(commands: Command[], filter: string) { +export function useFilteredCommands( + commands: TCommand[], + filter: string, +) { return commands.filter((cmd) => matchesFilter(cmd, filter)); } -export function SlashCommandPopover({ +export function SlashCommandPopover({ commands, filter, activeIndex, onSelect, onHover, -}: SlashCommandPopoverProps) { +}: SlashCommandPopoverProps) { const filtered = useFilteredCommands(commands, filter); if (filtered.length === 0) return null; diff --git a/src/components/message-list/FilePartView.tsx b/src/components/message-list/FilePartView.tsx index e8cdc85..8935e04 100644 --- a/src/components/message-list/FilePartView.tsx +++ b/src/components/message-list/FilePartView.tsx @@ -1,9 +1,9 @@ -import type { FilePart } from "@opencode-ai/sdk/v2/client"; import { useTranslation } from "react-i18next"; import { useConnectionState } from "@/hooks/use-agent-state"; import { resolveAttachmentImageSrc } from "@/lib/attachment-src"; +import type { FileTranscriptPart } from "@/protocol/session-transcript"; -export function FilePartView({ part }: { part: FilePart }) { +export function FilePartView({ part }: { part: FileTranscriptPart }) { const { t } = useTranslation(); const { workspaceServerUrl } = useConnectionState(); const isImage = (part.mime ?? "").toLowerCase().startsWith("image/"); diff --git a/src/components/message-list/MessageBubble.tsx b/src/components/message-list/MessageBubble.tsx index 0d54564..ca67716 100644 --- a/src/components/message-list/MessageBubble.tsx +++ b/src/components/message-list/MessageBubble.tsx @@ -7,10 +7,11 @@ import { type ImageMention, } from "@/components/ImageMentionPreview"; import { ProviderIcon } from "@/components/provider-icons"; -import { useConnectionState, useSessionState, type MessageEntry } from "@/hooks/use-agent-state"; +import { useConnectionState, useSessionState } from "@/hooks/use-agent-state"; import { USER_MSG_COLLAPSE_CHARS } from "@/lib/constants"; import { splitImageMentions } from "@/lib/image-mentions"; import { cn } from "@/lib/utils"; +import type { TranscriptMessageEntry } from "@/protocol/session-transcript"; import { DurationLabel } from "./DurationLabel"; import { PartView } from "./PartView"; import type { TurnFooter } from "./types"; @@ -22,19 +23,19 @@ export const MessageBubble = memo(function MessageBubble({ onFork, onRevert, expandedUserMessages, - expandedToolParts, + expandedToolCalls, onToggleUserMessage, - onToggleToolPart, + onToggleToolCall, }: { - entry: MessageEntry; + entry: TranscriptMessageEntry; turnFooter?: TurnFooter; lastReasoningPartId?: string; onFork?: () => void; onRevert?: () => void; expandedUserMessages?: ReadonlySet; - expandedToolParts?: ReadonlySet; + expandedToolCalls?: ReadonlySet; onToggleUserMessage?: (messageId: string) => void; - onToggleToolPart?: (partId: string, expanded: boolean) => void; + onToggleToolCall?: (partId: string, expanded: boolean) => void; }) { const { t } = useTranslation(); const { isLocalWorkspace, workspaceServerUrl } = useConnectionState(); @@ -140,8 +141,8 @@ export const MessageBubble = memo(function MessageBubble({ part={part} isUser={isUser} lastReasoningPartId={lastReasoningPartId} - expandedToolParts={expandedToolParts} - onToggleToolPart={onToggleToolPart} + expandedToolCalls={expandedToolCalls} + onToggleToolCall={onToggleToolCall} activeImagePath={activeImagePath} onImageHover={setActiveImagePath} onImageOpen={setOpenImage} diff --git a/src/components/message-list/PartView.tsx b/src/components/message-list/PartView.tsx index 848ddf5..469af84 100644 --- a/src/components/message-list/PartView.tsx +++ b/src/components/message-list/PartView.tsx @@ -1,7 +1,7 @@ -import type { Part } from "@opencode-ai/sdk/v2/client"; import { memo } from "react"; import type { ImageMention } from "@/components/ImageMentionPreview"; -import { ToolPartView } from "@/components/message-list/tools/ToolPartView"; +import { ToolCallPartView } from "@/components/message-list/tools/ToolCallPartView"; +import type { TranscriptPart } from "@/protocol/session-transcript"; import { FilePartView } from "./FilePartView"; import { ReasoningPartView } from "./ReasoningPartView"; import { TextPartView } from "./TextPartView"; @@ -10,18 +10,18 @@ export const PartView = memo(function PartView({ part, isUser, lastReasoningPartId, - expandedToolParts, - onToggleToolPart, + expandedToolCalls, + onToggleToolCall, activeImagePath, onImageHover, onImageOpen, imageBaseDirectory, }: { - part: Part; + part: TranscriptPart; isUser?: boolean; lastReasoningPartId?: string; - expandedToolParts?: ReadonlySet; - onToggleToolPart?: (partId: string, expanded: boolean) => void; + expandedToolCalls?: ReadonlySet; + onToggleToolCall?: (partId: string, expanded: boolean) => void; activeImagePath?: string | null; onImageHover?: (path: string | null) => void; onImageOpen?: (image: ImageMention) => void; @@ -45,10 +45,10 @@ export const PartView = memo(function PartView({ return ; case "tool": return ( - ); case "step-start": @@ -57,6 +57,8 @@ export const PartView = memo(function PartView({ case "patch": case "compaction": case "retry": + case "subtask": + case "agent": return null; default: return null; diff --git a/src/components/message-list/QuestionPanel.tsx b/src/components/message-list/QuestionPanel.tsx index 4492b29..a5a7303 100644 --- a/src/components/message-list/QuestionPanel.tsx +++ b/src/components/message-list/QuestionPanel.tsx @@ -1,17 +1,17 @@ -import type { QuestionAnswer, QuestionInfo } from "@opencode-ai/sdk/v2/client"; import { Check, MessageCircleQuestion, X } from "lucide-react"; import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import type { QuestionInteractionAnswer, QuestionPrompt } from "@/protocol/session-transcript"; export function QuestionPanel({ questions, onSubmit, onDismiss, }: { - questions: QuestionInfo[]; - onSubmit: (answers: QuestionAnswer[]) => void; + questions: QuestionPrompt[]; + onSubmit: (answers: QuestionInteractionAnswer[]) => void; onDismiss: () => void; }) { const { t } = useTranslation(); @@ -42,7 +42,7 @@ export function QuestionPanel({ }, []); const handleSubmit = useCallback(() => { - const answers: QuestionAnswer[] = questions.map((_q, i) => { + const answers: QuestionInteractionAnswer[] = questions.map((_q, i) => { const selected = selections[i] ?? []; const custom = (customTexts[i] ?? "").trim(); return custom ? [...selected, custom] : selected; diff --git a/src/components/message-list/ReasoningPartView.tsx b/src/components/message-list/ReasoningPartView.tsx index a8b5573..73825a6 100644 --- a/src/components/message-list/ReasoningPartView.tsx +++ b/src/components/message-list/ReasoningPartView.tsx @@ -1,10 +1,10 @@ -import type { ReasoningPart } from "@opencode-ai/sdk/v2/client"; import { ChevronRight } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { MarkdownRenderer } from "@/components/MarkdownRenderer"; import { useSessionState } from "@/hooks/use-agent-state"; import { cn } from "@/lib/utils"; +import type { ReasoningTranscriptPart } from "@/protocol/session-transcript"; import { formatDuration, hideZeroDurationLabel } from "./duration"; const TIMELINE_ROW_BASE = "flex min-w-0 items-center gap-1.5"; @@ -15,7 +15,7 @@ export function ReasoningPartView({ part, isLastReasoning, }: { - part: ReasoningPart; + part: ReasoningTranscriptPart; isLastReasoning?: boolean; }) { const isThinking = !part.time.end; diff --git a/src/components/message-list/TextPartView.tsx b/src/components/message-list/TextPartView.tsx index 2cefd16..6de65e5 100644 --- a/src/components/message-list/TextPartView.tsx +++ b/src/components/message-list/TextPartView.tsx @@ -1,8 +1,8 @@ -import type { TextPart } from "@opencode-ai/sdk/v2/client"; import { ImageMentionToken, type ImageMention } from "@/components/ImageMentionPreview"; import { MarkdownRenderer } from "@/components/MarkdownRenderer"; import { useConnectionState } from "@/hooks/use-agent-state"; import { splitImageMentions } from "@/lib/image-mentions"; +import type { TextTranscriptPart } from "@/protocol/session-transcript"; export function TextPartView({ part, @@ -12,7 +12,7 @@ export function TextPartView({ onImageOpen, imageBaseDirectory, }: { - part: TextPart; + part: TextTranscriptPart; isUser?: boolean; activeImagePath?: string | null; onImageHover?: (path: string | null) => void; diff --git a/src/components/message-list/interactions/InteractionRequestsView.tsx b/src/components/message-list/interactions/InteractionRequestsView.tsx new file mode 100644 index 0000000..9e10c55 --- /dev/null +++ b/src/components/message-list/interactions/InteractionRequestsView.tsx @@ -0,0 +1,64 @@ +import { ShieldAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { QuestionPanel } from "@/components/message-list/QuestionPanel"; +import { Button } from "@/components/ui/button"; +import type { + PermissionInteractionRequest, + QuestionInteractionAnswer, + QuestionInteractionRequest, +} from "@/protocol/session-transcript"; + +export function InteractionRequestsView({ + permission, + question, + onRespondPermission, + onReplyQuestion, + onRejectQuestion, +}: { + permission: PermissionInteractionRequest | null; + question: QuestionInteractionRequest | null; + onRespondPermission: (response: "once" | "always" | "reject") => void; + onReplyQuestion: (answers: QuestionInteractionAnswer[]) => void; + onRejectQuestion: () => void; +}) { + const { t } = useTranslation(); + + return ( + <> + {permission && ( +
+
+ +
+

+ {t("permissionPanel.title", { permission: permission.permission })} +

+ {permission.patterns.length > 0 && ( +

{permission.patterns.join(", ")}

+ )} +
+
+
+ + + +
+
+ )} + + {question && ( + + )} + + ); +} diff --git a/src/components/message-list/tools/ToolCallOutputView.tsx b/src/components/message-list/tools/ToolCallOutputView.tsx new file mode 100644 index 0000000..a61c3d8 --- /dev/null +++ b/src/components/message-list/tools/ToolCallOutputView.tsx @@ -0,0 +1,114 @@ +import { CheckCircle2, Circle, Wrench, XCircle } from "lucide-react"; +import { MarkdownRenderer } from "@/components/MarkdownRenderer"; +import { TerminalOutput } from "@/components/message-list/TerminalOutput"; +import { todoStatusConfig } from "@/lib/todos"; +import { cn, looksLikeTerminalOutput } from "@/lib/utils"; +import { ApplyPatchFilesView } from "./ApplyPatchFilesView"; +import type { ToolOutputBlock } from "./toolCallModel"; + +function ToolImages({ block }: { block: Extract }) { + return ( +
+ {block.images.map((image, idx) => ( +
+ {image.filename +
+ ))} +
+ ); +} + +export function ToolCallOutputView({ blocks }: { blocks: ToolOutputBlock[] }) { + return ( +
+ {blocks.map((block, index) => { + switch (block.type) { + case "text": + return block.format === "terminal" ? ( + + ) : ( +
+                {block.text}
+              
+ ); + case "diff": + return ; + case "images": + return ; + case "todos": + return ( +
+ {block.todos.map((todo, i) => { + const cfg = todoStatusConfig[todo.status] ?? { + icon: Circle, + color: "text-muted-foreground", + }; + const Icon = cfg.icon; + return ( +
+ + + {todo.content} + +
+ ); + })} +
+ ); + case "task": + return ( +
+ {block.taskInfo.toolCalls.length > 0 && ( +
+ {block.taskInfo.toolCalls.map((tc, i) => ( +
+ + {tc.tool} + {tc.title && ( + {tc.title} + )} + {tc.status === "completed" && ( + + )} + {tc.status === "error" && ( + + )} +
+ ))} +
+ )} + {block.taskInfo.output && + (looksLikeTerminalOutput(block.taskInfo.output) ? ( + + ) : ( +
+ +
+ ))} +
+ ); + } + })} +
+ ); +} diff --git a/src/components/message-list/tools/ToolCallPartView.tsx b/src/components/message-list/tools/ToolCallPartView.tsx new file mode 100644 index 0000000..f56b688 --- /dev/null +++ b/src/components/message-list/tools/ToolCallPartView.tsx @@ -0,0 +1,128 @@ +import { Check, ChevronRight, X } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Spinner } from "@/components/ui/spinner"; +import { useConnectionState } from "@/hooks/use-agent-state"; +import { cn } from "@/lib/utils"; +import type { ToolCallTranscriptPart } from "@/protocol/session-transcript"; +import { ToolCallOutputView } from "./ToolCallOutputView"; +import { getToolCallViewModel, type ToolCallStatus } from "./toolCallModel"; + +const ROW = "flex min-w-0 items-center gap-1.5"; +const BUTTON_RESET = "m-0 appearance-none border-0 bg-transparent p-0 text-left text-inherit"; + +function ToolCallIcon({ + status, + expandable, + expanded, +}: { + status: ToolCallStatus; + expandable: boolean; + expanded: boolean; +}) { + if (status === "running") return ; + if (status === "error") return ; + if (!expandable) return ; + return ( + + ); +} + +export function ToolCallPartView({ + part, + expandedToolCalls, + onToggleToolCall, +}: { + part: ToolCallTranscriptPart; + expandedToolCalls?: ReadonlySet; + onToggleToolCall?: (partId: string, expanded: boolean) => void; +}) { + const { workspaceServerUrl } = useConnectionState(); + const { t } = useTranslation(); + const tool = getToolCallViewModel(part, workspaceServerUrl, t); + const expanded = expandedToolCalls?.has(part.id) ?? false; + const setExpanded = (nextExpanded: boolean) => onToggleToolCall?.(part.id, nextExpanded); + const outputRef = useRef(null); + const shouldAutoExpand = + tool.status === "running" && + tool.expandable && + (tool.kind === "bash" || + (tool.kind === "task" && + tool.output.some((block) => block.type === "task" && block.taskInfo.childSessionId))); + + useEffect(() => { + if (shouldAutoExpand && !expanded) setExpanded(true); + }, [expanded, shouldAutoExpand]); + + useEffect(() => { + if (!expanded || tool.status !== "running" || (tool.kind !== "bash" && tool.kind !== "task")) { + return; + } + const el = outputRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [expanded, tool.status, tool.kind, tool.output]); + + const rowContent = ( + <> + + + + + {tool.label} + + {tool.matchCount != null && ( + + {tool.matchCount} {tool.matchCount === 1 ? "match" : "matches"} + + )} + {tool.diffSummary && ( + + +{tool.diffSummary.added} + -{tool.diffSummary.removed} + + )} + {tool.durationLabel && ( + + {tool.durationLabel} + + )} + + ); + + return ( +
+ {tool.expandable ? ( +
setExpanded(event.currentTarget.open)} + className="m-0" + > + + {rowContent} + +
+ ) : ( +
{rowContent}
+ )} + {tool.expandable && expanded && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/message-list/tools/ToolPartView.tsx b/src/components/message-list/tools/ToolPartView.tsx deleted file mode 100644 index 9dcc468..0000000 --- a/src/components/message-list/tools/ToolPartView.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client"; -import { - Check, - CheckCircle2, - ChevronRight, - Circle, - CircleCheck, - FileCode, - FileEdit, - FilePlus, - Layers, - MessageCircleQuestion, - Search, - SquareTerminal, - Wrench, - X, - XCircle, - type LucideIcon, -} from "lucide-react"; -import { useEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { MarkdownRenderer } from "@/components/MarkdownRenderer"; -import { TerminalOutput } from "@/components/message-list/TerminalOutput"; -import { Spinner } from "@/components/ui/spinner"; -import { useConnectionState } from "@/hooks/use-agent-state"; -import { todoStatusConfig, type TodoItem } from "@/lib/todos"; -import { cn, looksLikeTerminalOutput } from "@/lib/utils"; -import { ApplyPatchFilesView } from "./ApplyPatchFilesView"; -import { getToolPresentation, type ToolPresentation } from "./toolPresentation"; -import type { ToolKind } from "./toolTypes"; - -const TIMELINE_ROW_BASE = "flex min-w-0 items-center gap-1.5"; -const TIMELINE_BUTTON_RESET = - "m-0 appearance-none border-0 bg-transparent p-0 text-left text-inherit"; - -function toolIcon(kind: ToolKind): LucideIcon { - switch (kind) { - case "bash": - return SquareTerminal; - case "read": - return FileCode; - case "edit": - return FileEdit; - case "write": - return FilePlus; - case "grep": - case "glob": - return Search; - case "task": - return Layers; - case "todo": - return CircleCheck; - case "question": - return MessageCircleQuestion; - default: - return Wrench; - } -} - -function TodoListView({ todos }: { todos: TodoItem[] }) { - return ( -
- {todos.map((todo, i) => { - const cfg = todoStatusConfig[todo.status] ?? { - icon: Circle, - color: "text-muted-foreground", - }; - const Icon = cfg.icon; - const isCancelled = todo.status === "cancelled"; - return ( -
- - - {todo.content} - -
- ); - })} -
- ); -} - -function ToolHeader({ - presentation, - expanded, - setExpanded, -}: { - presentation: ToolPresentation; - expanded: boolean; - setExpanded: (expanded: boolean) => void; -}) { - const { t } = useTranslation(); - const { tool, expandable } = presentation; - const Icon = toolIcon(tool.kind); - const icon = expandable ? ( - - ) : tool.kind === "unknown" ? ( - - ) : ( - - ); - - const content = ( - <> - {icon} - - {presentation.title} - {presentation.hasDynamicLabel && tool.isRunning && !presentation.context ? "..." : ""} - - {presentation.context && ( - - {presentation.context} - {presentation.hasDynamicLabel && tool.isRunning ? "..." : ""} - - )} - {presentation.grepMatchCount != null && ( - - {t( - presentation.grepMatchCount === 1 - ? "toolLabels.matchCountOne" - : "toolLabels.matchCountOther", - { count: presentation.grepMatchCount }, - )} - - )} - {presentation.diffSummary && ( - - +{presentation.diffSummary.added} - -{presentation.diffSummary.removed} - - )} - {tool.kind === "task" && presentation.taskDurationLabel && ( - - {presentation.taskDurationLabel} - - )} - {tool.kind === "task" && tool.isRunning && } - - ); - - if (!expandable) { - return
{content}
; - } - - return ( -
setExpanded(e.currentTarget.open)} className="m-0"> - - {content} - -
- ); -} - -function ToolImages({ presentation }: { presentation: ToolPresentation }) { - const { t } = useTranslation(); - if (presentation.sideContent.images.length === 0) return null; - - return ( -
- {presentation.sideContent.images.map((image, idx) => ( -
- {image.filename -
- ))} -
- ); -} - -function ToolBody({ - presentation, - toolOutputRef, - taskContentRef, -}: { - presentation: ToolPresentation; - toolOutputRef: React.RefObject; - taskContentRef: React.RefObject; -}) { - const body = presentation.body; - if (!body) { - return ( -
- -
- ); - } - - switch (body.type) { - case "terminal": - return ( -
- - -
- ); - case "apply-patch": - return ( -
- -
- -
-
- ); - case "task": - return ( -
- {body.taskInfo.toolCalls.length > 0 && ( -
- {body.taskInfo.toolCalls.map((tc, i) => ( -
- - {tc.tool} - {tc.title && ( - {tc.title} - )} - {tc.status === "completed" && ( - - )} - {tc.status === "error" && ( - - )} -
- ))} -
- )} - {body.taskInfo.output && ( -
- {looksLikeTerminalOutput(body.taskInfo.output) ? ( - - ) : ( - - )} -
- )} - -
- ); - } -} - -export function ToolPartView({ - part, - expandedToolParts, - onToggleToolPart, -}: { - part: ToolPart; - expandedToolParts?: ReadonlySet; - onToggleToolPart?: (partId: string, expanded: boolean) => void; -}) { - const { workspaceServerUrl } = useConnectionState(); - const { t } = useTranslation(); - const presentation = getToolPresentation(part, workspaceServerUrl, t); - const expanded = expandedToolParts?.has(part.id) ?? false; - const setExpanded = (nextExpanded: boolean) => onToggleToolPart?.(part.id, nextExpanded); - const autoExpandedRef = useRef(false); - const bashAutoExpandedRef = useRef(false); - const taskContentRef = useRef(null); - const toolOutputRef = useRef(null); - - useEffect(() => { - if (presentation.tool.kind !== "task") return; - if ( - presentation.tool.isRunning && - presentation.taskInfo?.childSessionId && - !autoExpandedRef.current - ) { - setExpanded(true); - autoExpandedRef.current = true; - } else if (!presentation.tool.isRunning && autoExpandedRef.current) { - setExpanded(false); - } - }, [presentation.tool.kind, presentation.tool.isRunning, presentation.taskInfo?.childSessionId]); - - useEffect(() => { - if ( - presentation.tool.kind === "task" && - presentation.tool.isRunning && - expanded && - taskContentRef.current - ) { - taskContentRef.current.scrollTop = taskContentRef.current.scrollHeight; - } - }, [presentation.taskInfo, presentation.tool.kind, presentation.tool.isRunning, expanded]); - - useEffect(() => { - if ( - presentation.tool.kind !== "bash" || - !presentation.tool.isRunning || - !presentation.bashOutputText || - bashAutoExpandedRef.current - ) { - return; - } - setExpanded(true); - bashAutoExpandedRef.current = true; - }, [presentation.tool.kind, presentation.tool.isRunning, presentation.bashOutputText]); - - useEffect(() => { - if ( - presentation.tool.kind !== "bash" || - !expanded || - !presentation.bashOutputText || - !toolOutputRef.current - ) - return; - toolOutputRef.current.scrollTop = toolOutputRef.current.scrollHeight; - }, [presentation.tool.kind, expanded, presentation.bashOutputText]); - - const hasSideContent = - presentation.sideContent.todos && presentation.sideContent.todos.length > 0; - - return ( -
- - {presentation.expandable && expanded && ( - - )} - {presentation.error && - !(presentation.tool.kind === "bash" && presentation.bashOutputText?.trim()) && ( -
- {presentation.error} -
- )} - {hasSideContent && ( -
- {presentation.sideContent.todos && presentation.sideContent.todos.length > 0 && ( - - )} -
- )} -
- ); -} - -function ToolStatusIcon({ status }: { status: string }) { - switch (status) { - case "running": - case "pending": - return ; - case "completed": - return ; - case "error": - return ; - default: - return ; - } -} diff --git a/src/components/message-list/tools/applyPatch.ts b/src/components/message-list/tools/applyPatch.ts index 490ec1a..0398826 100644 --- a/src/components/message-list/tools/applyPatch.ts +++ b/src/components/message-list/tools/applyPatch.ts @@ -1,7 +1,7 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client"; import type { TFunction } from "i18next"; import { parseUnifiedDiff, type DiffLine, type DiffResult } from "@/lib/diff"; -import { getToolInput, isRecord, stringField, toFiniteNumber } from "./toolTypes"; +import type { ToolCallState } from "@/protocol/session-transcript"; +import { getToolInput, isRecord, stringField, toFiniteNumber } from "./toolCallUtils"; type ApplyPatchChangeType = "add" | "delete" | "move" | "update"; @@ -23,7 +23,7 @@ function computeApplyPatchDiff(file: Record): DiffResult | null return parseDiffText(file.diff); } -function extractApplyPatchFiles(state: ToolPart["state"]): ApplyPatchFileDiff[] { +function extractApplyPatchFiles(state: ToolCallState): ApplyPatchFileDiff[] { if (!("metadata" in state) || !isRecord(state.metadata)) return []; const rawFiles = state.metadata.files; if (!Array.isArray(rawFiles)) return []; @@ -67,7 +67,7 @@ function extractApplyPatchFiles(state: ToolPart["state"]): ApplyPatchFileDiff[] * Prefer rich backend metadata when present, but still create a single file row * from input.filePath/path so edit and patch tools use the same UI frame. */ -export function extractEditFiles(state: ToolPart["state"]): ApplyPatchFileDiff[] { +export function extractEditFiles(state: ToolCallState): ApplyPatchFileDiff[] { const metadataFiles = extractApplyPatchFiles(state); if (metadataFiles.length > 0) return metadataFiles; @@ -101,7 +101,7 @@ export function getApplyPatchActionLabel(file: ApplyPatchFileDiff, t: TFunction) export function getApplyPatchContextLabel( files: ApplyPatchFileDiff[], - t: TFunction, + t?: TFunction, ): string | null { if (files.length === 0) return null; if (files.length === 1) { @@ -111,7 +111,7 @@ export function getApplyPatchContextLabel( ? `${file.previousPath} -> ${file.path}` : file.path; } - return t("toolLabels.fileCountOther", { count: files.length }); + return t ? t("toolLabels.fileCountOther", { count: files.length }) : `${files.length} files`; } export function summarizeApplyPatchFiles(files: ApplyPatchFileDiff[]) { diff --git a/src/components/message-list/tools/imageAttachments.ts b/src/components/message-list/tools/imageAttachments.ts index 146568b..7c24168 100644 --- a/src/components/message-list/tools/imageAttachments.ts +++ b/src/components/message-list/tools/imageAttachments.ts @@ -1,5 +1,5 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client"; import { resolveAttachmentImageSrc } from "@/lib/attachment-src"; +import type { ToolCallState } from "@/protocol/session-transcript"; export interface ImageAttachmentInfo { url: string; @@ -9,7 +9,7 @@ export interface ImageAttachmentInfo { } export function extractImageAttachments( - state: ToolPart["state"], + state: ToolCallState, serverUrl?: string | null, ): ImageAttachmentInfo[] { if (state.status !== "completed") return []; @@ -20,10 +20,13 @@ export function extractImageAttachments( const mime = (att.mime ?? "").toLowerCase(); return mime === "image/png" || mime === "image/jpeg" || mime === "image/jpg"; }) - .map((att) => ({ - url: att.url, - src: resolveAttachmentImageSrc(att.url, serverUrl), - mime: att.mime, - filename: att.filename, - })); + .map((att) => { + const mime = att.mime ?? ""; + return { + url: att.url, + src: resolveAttachmentImageSrc(att.url, serverUrl), + mime, + filename: att.filename, + }; + }); } diff --git a/src/components/message-list/tools/taskTool.ts b/src/components/message-list/tools/taskTool.ts index 4e41a10..b7cdffa 100644 --- a/src/components/message-list/tools/taskTool.ts +++ b/src/components/message-list/tools/taskTool.ts @@ -1,5 +1,5 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client"; -import { isRecord } from "./toolTypes"; +import type { ToolCallState } from "@/protocol/session-transcript"; +import { isRecord } from "./toolCallUtils"; export interface TaskInfo { description: string; @@ -28,10 +28,9 @@ function formatDuration(ms: number): string { return `${hours}h ${String(remMinutes).padStart(2, "0")}m`; } -export function getTaskDurationLabel(state: ToolPart["state"]): string | null { +export function getTaskDurationLabel(state: ToolCallState): string | null { if ( (state.status === "completed" || state.status === "error") && - "time" in state && state.time && typeof state.time.start === "number" && typeof state.time.end === "number" @@ -43,7 +42,7 @@ export function getTaskDurationLabel(state: ToolPart["state"]): string | null { } /** Extract execution info from a task tool call (input for header, output/metadata for content). */ -export function extractTaskInfo(state: ToolPart["state"]): TaskInfo | null { +export function extractTaskInfo(state: ToolCallState): TaskInfo | null { const input = "input" in state && isRecord(state.input) ? state.input : null; const description = input && typeof input.description === "string" ? input.description.trim() : ""; diff --git a/src/components/message-list/tools/toolCallModel.test.ts b/src/components/message-list/tools/toolCallModel.test.ts new file mode 100644 index 0000000..afbafc4 --- /dev/null +++ b/src/components/message-list/tools/toolCallModel.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "@voidzero-dev/vite-plus-test"; +import type { ToolCallState } from "@/protocol/session-transcript"; +import { getToolCallViewModel } from "./toolCallModel"; + +function toolPart(part: { tool: string; state: ToolCallState }) { + return { + id: "part-1", + type: "tool", + ...part, + } as Parameters[0]; +} + +describe("getToolCallViewModel", () => { + test("presents unknown failed tools with a prettified label and no expansion", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "sengine_scrape", + state: { status: "error", error: "Sengine request failed (500): goto:" }, + }), + ); + + expect(vm.status).toBe("error"); + expect(vm.label).toBe("Sengine Scrape"); + expect(vm.expandable).toBe(false); + expect(vm.output).toEqual([ + { type: "text", text: "Sengine request failed (500): goto:", format: "plain" }, + ]); + }); + + test("makes completed tools with meaningful output expandable", () => { + const vm = getToolCallViewModel( + toolPart({ tool: "sengine_scrape", state: { status: "completed", output: "result" } }), + ); + + expect(vm.status).toBe("success"); + expect(vm.expandable).toBe(true); + }); + + test("does not make empty or prompt-marker output expandable", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "sengine_scrape", + state: { status: "completed", output: "\n>\n>\n>\n" }, + }), + ); + + expect(vm.status).toBe("success"); + expect(vm.expandable).toBe(false); + expect(vm.output).toEqual([]); + }); + + test("maps protocol pending to product running", () => { + const vm = getToolCallViewModel( + toolPart({ tool: "bash", state: { status: "pending", input: { command: "vp check" } } }), + ); + + expect(vm.status).toBe("running"); + expect(vm.label).toBe("Running vp check"); + }); + + test("question-shaped tools are not a special tool kind", () => { + const vm = getToolCallViewModel( + toolPart({ tool: "ask_user", state: { status: "completed", output: "ok" } }), + ); + + expect(vm.kind).toBe("unknown"); + expect(vm.label).toBe("Ask User"); + }); +}); diff --git a/src/components/message-list/tools/toolCallModel.ts b/src/components/message-list/tools/toolCallModel.ts new file mode 100644 index 0000000..bae36f2 --- /dev/null +++ b/src/components/message-list/tools/toolCallModel.ts @@ -0,0 +1,258 @@ +import type { TodoItem } from "@/lib/todos"; +import type { TFunction } from "i18next"; +import { extractTodos } from "@/lib/todos"; +import { looksLikeTerminalOutput } from "@/lib/utils"; +import type { ToolCallState, ToolCallTranscriptPart } from "@/protocol/session-transcript"; +import type { ApplyPatchFileDiff } from "./applyPatch"; +import { + extractEditFiles, + getApplyPatchContextLabel, + summarizeApplyPatchFiles, +} from "./applyPatch"; +import { extractImageAttachments, type ImageAttachmentInfo } from "./imageAttachments"; +import { extractTaskInfo, getTaskDurationLabel, type TaskInfo } from "./taskTool"; +import { getToolInput, isRecord, prettifyToolName, stringField } from "./toolCallUtils"; + +export type ToolCallStatus = "running" | "success" | "error"; +export type ToolCallKind = + | "read" + | "bash" + | "edit" + | "write" + | "grep" + | "glob" + | "todo" + | "task" + | "browser" + | "fetch" + | "unknown"; + +export type ToolOutputBlock = + | { type: "text"; text: string; format: "plain" | "terminal" } + | { type: "images"; images: ImageAttachmentInfo[] } + | { type: "diff"; files: ApplyPatchFileDiff[] } + | { type: "task"; taskInfo: TaskInfo } + | { type: "todos"; todos: TodoItem[] }; + +export interface ToolCallViewModel { + id: string; + rawName: string; + kind: ToolCallKind; + status: ToolCallStatus; + label: string; + matchCount: number | null; + diffSummary: { added: number; removed: number } | null; + durationLabel: string | null; + output: ToolOutputBlock[]; + expandable: boolean; +} + +const KNOWN_TOOLS: Record = { + read: "read", + mcp_read: "read", + bash: "bash", + shell: "bash", + execute_command: "bash", + terminal: "bash", + run_command: "bash", + edit: "edit", + patch: "edit", + apply_patch: "edit", + str_replace: "edit", + replace: "edit", + multi_edit: "edit", + write: "write", + create_file: "write", + overwrite: "write", + grep: "grep", + mcp_grep: "grep", + search: "grep", + rg: "grep", + ripgrep: "grep", + glob: "glob", + mcp_glob: "glob", + find: "glob", + list: "glob", + ls: "glob", + todowrite: "todo", + todo_write: "todo", + update_plan: "todo", + plan: "todo", + task: "task", + subagent: "task", + delegate: "task", + browser: "browser", + screenshot: "browser", + click: "browser", + navigate: "browser", + open_url: "browser", + fetch: "fetch", + http: "fetch", + web_fetch: "fetch", + curl: "fetch", +}; + +function normalizeKind(name: string): ToolCallKind { + return KNOWN_TOOLS[name.toLowerCase()] ?? "unknown"; +} + +function normalizeStatus(status: ToolCallState["status"]): ToolCallStatus { + if (status === "error") return "error"; + if (status === "completed") return "success"; + return "running"; +} + +function rawOutput(state: ToolCallState): string | null { + return "output" in state && typeof state.output === "string" ? state.output : null; +} + +function metadataOutput(state: ToolCallState): string | null { + if (!("metadata" in state) || !isRecord(state.metadata)) return null; + return typeof state.metadata.output === "string" ? state.metadata.output : null; +} + +function errorOutput(state: ToolCallState): string | null { + return "error" in state && typeof state.error === "string" ? state.error : null; +} + +function meaningfulText(value: string | null | undefined): string | null { + if (!value) return null; + const lines = value + .replace(/\r\n/g, "\n") + .split("\n") + .filter((line) => line.trim() && !/^>+$/.test(line.trim())); + const text = lines.join("\n").trim(); + return text ? text : null; +} + +function labelFor( + kind: ToolCallKind, + part: ToolCallTranscriptPart, + running: boolean, + input: Record | null, + editFiles: ApplyPatchFileDiff[], + taskInfo: TaskInfo | null, + t?: TFunction, +): string { + const path = stringField(input, "filePath") ?? stringField(input, "path"); + const command = stringField(input, "command"); + const pattern = stringField(input, "pattern"); + switch (kind) { + case "bash": + return command + ? `${t?.(running ? "toolLabels.bash.running" : "toolLabels.bash.done") ?? (running ? "Running" : "Ran")} ${command}` + : (t?.(running ? "toolLabels.bash.running" : "toolLabels.bash.done") ?? + (running ? "Running" : "Ran")); + case "read": + return path + ? `${t?.(running ? "toolLabels.read.running" : "toolLabels.read.done") ?? (running ? "Reading" : "Read")} ${path}` + : (t?.(running ? "toolLabels.read.running" : "toolLabels.read.done") ?? + (running ? "Reading" : "Read")); + case "write": + return path + ? `${t?.(running ? "toolLabels.write.running" : "toolLabels.write.done") ?? (running ? "Writing" : "Wrote")} ${path}` + : (t?.(running ? "toolLabels.write.running" : "toolLabels.write.done") ?? + (running ? "Writing" : "Wrote")); + case "edit": { + const target = getApplyPatchContextLabel(editFiles, t) ?? path; + const verb = running + ? (t?.("toolLabels.edit.running") ?? "Editing") + : part.tool.toLowerCase() === "apply_patch" + ? (t?.("toolLabels.patch.patched") ?? "Patched") + : (t?.("toolLabels.edit.done") ?? "Edited"); + return target ? `${verb} ${target}` : verb; + } + case "grep": + return pattern + ? `${t?.(running ? "toolLabels.grep.running" : "toolLabels.grep.done") ?? (running ? "Searching" : "Searched")} ${pattern}` + : (t?.(running ? "toolLabels.grep.running" : "toolLabels.grep.done") ?? + (running ? "Searching" : "Searched")); + case "glob": + return pattern + ? `${t?.(running ? "toolLabels.glob.running" : "toolLabels.glob.done") ?? (running ? "Globbing" : "Globbed")} ${pattern}` + : (t?.(running ? "toolLabels.glob.running" : "toolLabels.glob.done") ?? + (running ? "Globbing" : "Globbed")); + case "todo": { + const count = Array.isArray(input?.todos) ? input.todos.length : 0; + return running + ? (t?.("toolLabels.todo.running") ?? "Writing todos") + : (t?.(count === 1 ? "toolLabels.todo.doneOne" : "toolLabels.todo.doneOther", { count }) ?? + `Wrote ${count} todos`); + } + case "task": { + const subagent = stringField(input, "subagent_type") ?? stringField(input, "subagentType"); + return subagent + ? prettifyToolName(subagent) + : taskInfo?.description || + (t?.(running ? "toolLabels.task.running" : "toolLabels.task.done") ?? + (running ? "Running" : "Ran")); + } + case "browser": + case "fetch": + case "unknown": + return prettifyToolName(part.tool); + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +export function getToolCallViewModel( + part: ToolCallTranscriptPart, + serverUrl?: string | null, + t?: TFunction, +): ToolCallViewModel { + const state = part.state; + const status = normalizeStatus(state.status); + const running = status === "running"; + const input = getToolInput(state); + const kind = normalizeKind(part.tool); + const text = rawOutput(state); + const error = errorOutput(state); + const bashText = + kind === "bash" + ? running + ? (metadataOutput(state) ?? text) + : (text ?? metadataOutput(state) ?? error) + : null; + const editFiles = kind === "edit" ? extractEditFiles(state) : []; + const taskInfo = kind === "task" ? extractTaskInfo(state) : null; + const todos = kind === "todo" ? extractTodos(state) : null; + const images = extractImageAttachments(state, serverUrl); + const output: ToolOutputBlock[] = []; + + if (editFiles.length > 0) output.push({ type: "diff", files: editFiles }); + else if (taskInfo) output.push({ type: "task", taskInfo }); + else { + const content = meaningfulText( + kind === "bash" ? bashText : status === "error" ? (error ?? text) : text, + ); + if (content) + output.push({ + type: "text", + text: content, + format: kind === "bash" || looksLikeTerminalOutput(content) ? "terminal" : "plain", + }); + } + + if (todos?.length) output.push({ type: "todos", todos }); + if (images.length) output.push({ type: "images", images }); + + const grepText = meaningfulText(text); + const match = kind === "grep" ? grepText?.match(/^Found (\d+) match/) : null; + const matchCount = match ? Number.parseInt(match[1] ?? "", 10) : null; + + return { + id: part.id, + rawName: part.tool, + kind, + status, + label: labelFor(kind, part, running, input, editFiles, taskInfo, t), + matchCount: Number.isFinite(matchCount) ? matchCount : null, + diffSummary: summarizeApplyPatchFiles(editFiles), + durationLabel: kind === "task" ? getTaskDurationLabel(state) : null, + output, + expandable: status !== "error" && output.length > 0, + }; +} diff --git a/src/components/message-list/tools/toolCallUtils.ts b/src/components/message-list/tools/toolCallUtils.ts new file mode 100644 index 0000000..0f4f729 --- /dev/null +++ b/src/components/message-list/tools/toolCallUtils.ts @@ -0,0 +1,29 @@ +import type { ToolCallState } from "@/protocol/session-transcript"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function getToolInput(state: ToolCallState): Record | null { + return "input" in state && isRecord(state.input) ? state.input : null; +} + +export function stringField(record: Record | null | undefined, key: string) { + const value = record?.[key]; + return typeof value === "string" ? value : null; +} + +export function toFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function prettifyToolName(rawName: string): string { + return ( + rawName + .replace(/^mcp[_-]/i, "") + .split(/[_\s-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" ") || rawName + ); +} diff --git a/src/components/message-list/tools/toolPresentation.ts b/src/components/message-list/tools/toolPresentation.ts deleted file mode 100644 index 1e03933..0000000 --- a/src/components/message-list/tools/toolPresentation.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client"; -import type { TFunction } from "i18next"; -import type { TodoItem } from "@/lib/todos"; -import { extractTodos } from "@/lib/todos"; -import type { ApplyPatchFileDiff } from "./applyPatch"; -import { - extractEditFiles, - getApplyPatchContextLabel, - summarizeApplyPatchFiles, -} from "./applyPatch"; -import { extractImageAttachments, type ImageAttachmentInfo } from "./imageAttachments"; -import { extractTaskInfo, getTaskDurationLabel, type TaskInfo } from "./taskTool"; -import { - getToolInput, - isRecord, - isToolRunning, - normalizeToolKind, - normalizeToolVariant, - stringField, - type ToolKind, - type ToolVariant, -} from "./toolTypes"; - -export type ToolBody = - | { type: "terminal"; content: string } - | { type: "apply-patch"; files: ApplyPatchFileDiff[] } - | { type: "task"; taskInfo: TaskInfo } - | null; - -interface NormalizedTool { - rawName: string; - kind: ToolKind; - variant: ToolVariant; - status: ToolPart["state"]["status"]; - isRunning: boolean; -} - -export interface ToolPresentation { - tool: NormalizedTool; - title: string; - context: string | null; - hasDynamicLabel: boolean; - grepMatchCount: number | null; - diffSummary: { added: number; removed: number } | null; - taskDurationLabel: string | null; - expandable: boolean; - body: ToolBody; - sideContent: { - todos: TodoItem[] | null; - images: ImageAttachmentInfo[]; - }; - error: string | null; - taskInfo: TaskInfo | null; - bashOutputText: string | null; -} - -function getRawOutputText(state: ToolPart["state"]): string | null { - return "output" in state && typeof state.output === "string" ? state.output : null; -} - -function getBashMetadataOutput(state: ToolPart["state"]): string | null { - if (!("metadata" in state) || !isRecord(state.metadata)) return null; - return typeof state.metadata.output === "string" ? state.metadata.output : null; -} - -function getErrorText(state: ToolPart["state"]): string | null { - return "error" in state && typeof state.error === "string" ? state.error : null; -} - -function getToolTitle(part: ToolPart, kind: ToolKind, isRunning: boolean, t: TFunction): string { - const input = getToolInput(part.state); - const title = - "title" in part.state && typeof part.state.title === "string" ? part.state.title : null; - - switch (kind) { - case "bash": - return isRunning ? t("toolLabels.bash.running") : t("toolLabels.bash.done"); - case "read": - return isRunning ? t("toolLabels.read.running") : t("toolLabels.read.done"); - case "edit": - return isRunning - ? t("toolLabels.edit.running") - : part.tool.toLowerCase() === "apply_patch" - ? t("toolLabels.patch.patched") - : t("toolLabels.edit.done"); - case "write": - return isRunning ? t("toolLabels.write.running") : t("toolLabels.write.done"); - case "grep": - return isRunning ? t("toolLabels.grep.running") : t("toolLabels.grep.done"); - case "glob": - return isRunning ? t("toolLabels.glob.running") : t("toolLabels.glob.done"); - case "task": { - const subagent = input?.subagent_type ?? input?.subagentType; - return typeof subagent === "string" - ? subagent.charAt(0).toUpperCase() + subagent.slice(1) - : isRunning - ? t("toolLabels.task.running") - : t("toolLabels.task.done"); - } - case "todo": { - const todoCount = Array.isArray(input?.todos) ? input.todos.length : 0; - return isRunning - ? t("toolLabels.todo.running") - : t(todoCount === 1 ? "toolLabels.todo.doneOne" : "toolLabels.todo.doneOther", { - count: todoCount, - }); - } - case "question": { - const qCount = Array.isArray(input?.questions) ? input.questions.length : 0; - return isRunning - ? t("toolLabels.question.running") - : t(qCount === 1 ? "toolLabels.question.doneOne" : "toolLabels.question.doneOther", { - count: qCount, - }); - } - default: - return title ?? part.tool; - } -} - -export function getToolPresentation( - part: ToolPart, - workspaceServerUrl: string | null | undefined, - t: TFunction, -): ToolPresentation { - const state = part.state; - const kind = normalizeToolKind(part.tool); - const variant = normalizeToolVariant(part.tool); - const isRunning = isToolRunning(state.status); - const input = getToolInput(state); - - const normalized: NormalizedTool = { - rawName: part.tool, - kind, - variant, - status: state.status, - isRunning, - }; - - const rawOutputText = getRawOutputText(state); - const outputText = rawOutputText?.trim() || null; - const errorText = getErrorText(state); - const bashMetadataOutput = kind === "bash" ? getBashMetadataOutput(state) : null; - const bashOutputText = - kind === "bash" - ? isRunning - ? (bashMetadataOutput ?? rawOutputText) - : (rawOutputText ?? bashMetadataOutput ?? errorText) - : null; - - const editFiles = kind === "edit" ? extractEditFiles(state) : []; - const editSummary = summarizeApplyPatchFiles(editFiles); - const todos = kind === "todo" ? extractTodos(state) : null; - const taskInfo = kind === "task" ? extractTaskInfo(state) : null; - const taskDurationLabel = kind === "task" ? getTaskDurationLabel(state) : null; - const images = extractImageAttachments(state, workspaceServerUrl); - - const command = kind === "bash" ? stringField(input, "command") : null; - const globPattern = kind === "glob" ? stringField(input, "pattern") : null; - const grepPattern = kind === "grep" ? stringField(input, "pattern") : null; - const writeContentText = - kind === "write" ? (stringField(input, "content") ?? rawOutputText) : null; - const filePath = - kind === "read" || kind === "edit" || kind === "write" - ? (stringField(input, "filePath") ?? stringField(input, "path")) - : null; - const taskDescription = - kind === "task" && taskInfo?.description ? `(${taskInfo.description})` : null; - const editFilesLabel = kind === "edit" ? getApplyPatchContextLabel(editFiles, t) : null; - const stateTitle = - state.status === "completed" && - state.title && - kind !== "todo" && - kind !== "task" && - kind !== "question" - ? state.title - : null; - const context = - filePath ?? - editFilesLabel ?? - command ?? - grepPattern ?? - globPattern ?? - taskDescription ?? - stateTitle ?? - null; - - const grepMatchCount = - kind === "grep" && outputText - ? (() => { - const match = outputText.match(/^Found (\d+) match/); - return match?.[1] ? Number.parseInt(match[1], 10) : null; - })() - : null; - - const hasEditFileView = editFiles.length > 0; - const genericOutputText = - kind === "bash" ? bashOutputText : kind === "write" ? writeContentText : rawOutputText; - const body: ToolBody = hasEditFileView - ? { type: "apply-patch", files: editFiles } - : kind === "task" && taskInfo - ? { type: "task", taskInfo } - : genericOutputText?.trim() - ? { type: "terminal", content: genericOutputText } - : null; - - return { - tool: normalized, - title: getToolTitle(part, kind, isRunning, t), - context, - hasDynamicLabel: [ - "read", - "edit", - "bash", - "write", - "grep", - "glob", - "task", - "todo", - "question", - ].includes(kind), - grepMatchCount, - diffSummary: editSummary, - taskDurationLabel, - expandable: body !== null || images.length > 0, - body, - sideContent: { todos, images }, - error: state.status === "error" && errorText ? errorText : null, - taskInfo, - bashOutputText, - }; -} diff --git a/src/components/message-list/tools/toolTypes.ts b/src/components/message-list/tools/toolTypes.ts deleted file mode 100644 index d842a19..0000000 --- a/src/components/message-list/tools/toolTypes.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { ToolPart } from "@opencode-ai/sdk/v2/client"; - -export type ToolKind = - | "read" - | "bash" - | "edit" - | "write" - | "grep" - | "glob" - | "todo" - | "question" - | "task" - | "browser" - | "fetch" - | "unknown"; - -export type ToolVariant = - | "default" - | "mcp" - | "shell" - | "execute_command" - | "patch" - | "apply_patch" - | "search" - | "find" - | "subagent" - | "browser" - | "fetch"; - -type ToolStatus = ToolPart["state"]["status"]; - -const TOOL_ALIASES = { - read: "read", - mcp_read: "read", - - bash: "bash", - shell: "bash", - execute_command: "bash", - terminal: "bash", - run_command: "bash", - - edit: "edit", - patch: "edit", - apply_patch: "edit", - str_replace: "edit", - replace: "edit", - multi_edit: "edit", - - write: "write", - create_file: "write", - overwrite: "write", - - grep: "grep", - mcp_grep: "grep", - search: "grep", - rg: "grep", - ripgrep: "grep", - - glob: "glob", - mcp_glob: "glob", - find: "glob", - list: "glob", - ls: "glob", - - todowrite: "todo", - todo_write: "todo", - update_plan: "todo", - plan: "todo", - - question: "question", - mcp_question: "question", - ask_user: "question", - input: "question", - - task: "task", - subagent: "task", - delegate: "task", - - browser: "browser", - screenshot: "browser", - click: "browser", - navigate: "browser", - open_url: "browser", - - fetch: "fetch", - http: "fetch", - web_fetch: "fetch", - curl: "fetch", -} as const satisfies Record; - -export function normalizeToolKind(rawName: string): ToolKind { - return TOOL_ALIASES[rawName.toLowerCase() as keyof typeof TOOL_ALIASES] ?? "unknown"; -} - -export function normalizeToolVariant(rawName: string): ToolVariant { - const lower = rawName.toLowerCase(); - if (lower.startsWith("mcp_")) return "mcp"; - if (lower === "shell") return "shell"; - if (lower === "execute_command") return "execute_command"; - if (lower === "patch") return "patch"; - if (lower === "apply_patch") return "apply_patch"; - if (lower === "search" || lower === "rg" || lower === "ripgrep") return "search"; - if (lower === "find" || lower === "list" || lower === "ls") return "find"; - if (lower === "subagent" || lower === "delegate") return "subagent"; - if (normalizeToolKind(lower) === "browser") return "browser"; - if (normalizeToolKind(lower) === "fetch") return "fetch"; - return "default"; -} - -export function isToolRunning(status: ToolStatus): boolean { - return status === "running" || status === "pending"; -} - -export function getToolInput(state: ToolPart["state"]): Record | null { - return "input" in state && isRecord(state.input) ? state.input : null; -} - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -export function stringField(record: Record | null | undefined, key: string) { - const value = record?.[key]; - return typeof value === "string" ? value : null; -} - -export function toFiniteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} diff --git a/src/components/no-sdk-imports.test.ts b/src/components/no-sdk-imports.test.ts new file mode 100644 index 0000000..1195ac5 --- /dev/null +++ b/src/components/no-sdk-imports.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "@voidzero-dev/vite-plus-test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const COMPONENTS_DIR = join(process.cwd(), "src", "components"); +const OPENCODE_SDK_IMPORT = ["@opencode-ai", "sdk"].join("/"); + +function collectSourceFiles(directory: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory)) { + const path = join(directory, entry); + const stat = statSync(path); + if (stat.isDirectory()) { + files.push(...collectSourceFiles(path)); + continue; + } + if (/\.(ts|tsx)$/.test(entry)) files.push(path); + } + return files; +} + +describe("Frontend presentation seam", () => { + test("components do not import Harness SDK types directly", () => { + const offenders = collectSourceFiles(COMPONENTS_DIR).filter((file) => + readFileSync(file, "utf8").includes(OPENCODE_SDK_IMPORT), + ); + + expect(offenders.map((file) => relative(process.cwd(), file))).toEqual([]); + }); +}); diff --git a/src/components/settings/McpSettings.tsx b/src/components/settings/McpSettings.tsx index 5daad1c..86baa07 100644 --- a/src/components/settings/McpSettings.tsx +++ b/src/components/settings/McpSettings.tsx @@ -1,5 +1,4 @@ import { AlertCircle, CheckCircle2, Globe, Terminal } from "lucide-react"; -import type { McpStatus } from "@opencode-ai/sdk/v2/client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { HARNESS_LABELS, type HarnessId } from "@/agents"; @@ -10,6 +9,7 @@ import { Switch } from "@/components/ui/switch"; import { useConnectionState } from "@/hooks/use-agent-state"; import { useHarness, useAvailableHarnessIds, useCurrentHarnessId } from "@/hooks/use-agent-backend"; import { MCP_TOGGLE_DELAY_MS } from "@/lib/constants"; +import type { McpServerStatus } from "@/protocol/harness-resources"; import { useOpenGuiClient } from "@/protocol/provider"; // --------------------------------------------------------------------------- @@ -32,7 +32,7 @@ export function McpTabContent() { const { activeDirectory, activeWorkspaceId } = useConnectionState(); const scopedDirectory = activeDirectory ?? undefined; - const [mcpStatus, setMcpStatus] = useState<{ [key: string]: McpStatus }>({}); + const [mcpStatus, setMcpStatus] = useState<{ [key: string]: McpServerStatus }>({}); const [mcpTypes, setMcpTypes] = useState<{ [key: string]: "local" | "remote"; }>({}); @@ -74,7 +74,7 @@ export function McpTabContent() { void refresh(); }, [refresh]); - const handleToggle = async (name: string, currentStatus: McpStatus) => { + const handleToggle = async (name: string, currentStatus: McpServerStatus) => { if (!mcpApi) return; setToggling(name); try { diff --git a/src/lib/todos.ts b/src/lib/todos.ts index e9d05e2..7c8446f 100644 --- a/src/lib/todos.ts +++ b/src/lib/todos.ts @@ -5,8 +5,8 @@ * inline TodoListView in MessageList. */ -import type { ToolPart } from "@opencode-ai/sdk/v2/client"; import { Circle, CircleCheck, CircleDot, CircleOff } from "lucide-react"; +import type { ToolCallState } from "@/protocol/session-transcript"; // --------------------------------------------------------------------------- // Types @@ -49,10 +49,10 @@ export const todoStatusConfig: Record< // --------------------------------------------------------------------------- /** Try to extract a todo array from a todowrite tool part's state. */ -export function extractTodos(state: ToolPart["state"]): TodoItem[] | null { +export function extractTodos(state: ToolCallState): TodoItem[] | null { try { - if ("input" in state && state.input) { - const raw = state.input.todos; + if ("input" in state && state.input && typeof state.input === "object") { + const raw = (state.input as Record).todos; if (Array.isArray(raw) && raw.length > 0) { return raw.filter( (t): t is TodoItem => diff --git a/src/protocol/harness-resources.ts b/src/protocol/harness-resources.ts new file mode 100644 index 0000000..86a2cb3 --- /dev/null +++ b/src/protocol/harness-resources.ts @@ -0,0 +1,25 @@ +/** OpenGUI-owned presentation shapes for Harness resource catalogs. */ + +export interface ProviderResource { + id: string; + name?: string; +} + +export interface SlashCommandResource { + name: string; + description?: string; + source?: string; +} + +export type McpConnectionStatus = + | "connected" + | "disabled" + | "failed" + | "needs_auth" + | "needs_client_registration" + | (string & {}); + +export interface McpServerStatus { + status: McpConnectionStatus; + error?: string; +} diff --git a/src/protocol/session-transcript.ts b/src/protocol/session-transcript.ts new file mode 100644 index 0000000..c113611 --- /dev/null +++ b/src/protocol/session-transcript.ts @@ -0,0 +1,158 @@ +/** + * OpenGUI-owned Session transcript presentation types. + * + * These shapes are the Frontend seam for rendering Harness-sourced transcript + * content. Harness SDK/native types should be adapted into these types before + * React presentation code consumes them. + */ + +export interface TranscriptMessageEntry { + info: TranscriptMessage; + parts: TranscriptPart[]; +} + +export interface TranscriptMessageError { + name: string; + data?: unknown; +} + +export interface TranscriptMessage { + id: string; + sessionID: string; + role: "user" | "assistant" | (string & {}); + time: { + created: number; + completed?: number; + }; + error?: TranscriptMessageError; + summary?: boolean | object; + providerID?: string; + modelID?: string; + variant?: string; + model?: { + providerID?: string; + modelID?: string; + variant?: string; + }; +} + +export type TranscriptPart = + | TextTranscriptPart + | FileTranscriptPart + | ReasoningTranscriptPart + | ToolCallTranscriptPart + | NonRenderableTranscriptPart; + +export interface BaseTranscriptPart { + id: string; + sessionID?: string; + messageID?: string; +} + +export interface TextTranscriptPart extends BaseTranscriptPart { + type: "text"; + text: string; + synthetic?: boolean; + ignored?: boolean; + time?: TranscriptPartTime; + metadata?: Record; +} + +export interface FileTranscriptPart extends BaseTranscriptPart { + type: "file"; + mime?: string; + filename?: string; + url: string; + source?: unknown; +} + +export interface ReasoningTranscriptPart extends BaseTranscriptPart { + type: "reasoning"; + text: string; + metadata?: Record; + time: TranscriptPartTime; +} + +export interface ToolCallTranscriptPart extends BaseTranscriptPart { + type: "tool"; + callID?: string; + tool: string; + state: ToolCallState; + metadata?: Record; +} + +export type ToolCallStateStatus = "pending" | "running" | "completed" | "error" | (string & {}); + +export interface ToolCallState { + status: ToolCallStateStatus; + input?: unknown; + raw?: string; + title?: string; + output?: unknown; + error?: unknown; + metadata?: unknown; + time?: TranscriptPartTime; + attachments?: TranscriptAttachment[]; +} + +export interface TranscriptPartTime { + start?: number; + end?: number; + compacted?: number; +} + +export interface TranscriptAttachment { + mime?: string; + filename?: string; + url: string; +} + +export type NonRenderableTranscriptPart = + | ({ type: "step-start" } & BaseTranscriptPart) + | ({ type: "step-finish" } & BaseTranscriptPart) + | ({ type: "snapshot" } & BaseTranscriptPart) + | ({ type: "patch" } & BaseTranscriptPart) + | ({ type: "compaction" } & BaseTranscriptPart) + | ({ type: "retry" } & BaseTranscriptPart) + | ({ type: "subtask" } & BaseTranscriptPart) + | ({ type: "agent" } & BaseTranscriptPart); + +export type InteractionRequest = PermissionInteractionRequest | QuestionInteractionRequest; + +export interface PermissionInteractionRequest { + id: string; + sessionID: string; + permission: string; + patterns: string[]; + metadata?: Record; + always?: string[]; + tool?: { + messageID: string; + callID: string; + }; +} + +export interface QuestionInteractionRequest { + id: string; + sessionID: string; + questions: QuestionPrompt[]; + tool?: { + messageID: string; + callID: string; + }; +} + +export interface QuestionPrompt { + question: string; + header: string; + options: QuestionPromptOption[]; + multiple?: boolean; + custom?: boolean; +} + +export interface QuestionPromptOption { + label: string; + description?: string; +} + +export type QuestionInteractionAnswer = string[];