import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { InternalTuiPlugin } from "../../plugin/internal" import { useSyncV2 } from "@tui/context/sync-v2" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" import { reasoningTitle, useThinkingMode } from "@tui/context/thinking" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { useBindings } from "../../keymap" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { webSearchProviderLabel } from "@/tool/websearch" import path from "path" import stripAnsi from "strip-ansi" import type { SessionMessage, SessionMessageAgentSwitched, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, SessionMessageCompaction, SessionMessageModelSwitched, SessionMessageShell, SessionMessageSynthetic, SessionMessageUser, ToolFileContent, ToolTextContent, } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" import { collapseToolOutput } from "../../util/collapse-tool-output" const id = "internal:session-v2-debug" const route = "session.v2.messages" function currentSessionID(api: TuiPluginApi) { const current = api.route.current if (current.name !== "session") return const sessionID = current.params?.sessionID return typeof sessionID === "string" ? sessionID : undefined } function View(props: { api: TuiPluginApi; sessionID: string }) { const sync = useSyncV2() const dimensions = useTerminalDimensions() const { theme, syntax, subtleSyntax } = useTheme() const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) const renderedMessages = createMemo(() => messages().toReversed()) const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) const lastUserCreated = (index: number) => renderedMessages() .slice(0, index) .findLast((message) => message.type === "user")?.time.created createEffect(() => { void sync.session.message.sync(props.sessionID) }) useBindings(() => ({ bindings: [ { key: "escape", desc: "Back to session", group: "Session", cmd() { props.api.route.navigate("session", { sessionID: props.sessionID }) }, }, ], })) return ( {(message, index) => ( <> )} ) } function MissingData(props: { label: string; detail: string }) { const { theme } = useTheme() return ( MISSING DATA {props.label} {props.detail} ) } function UserMessage(props: { message: SessionMessageUser; index: number }) { const { theme } = useTheme() const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])]) return ( {props.message.text} {(file) => ( {file.mime} {file.name ?? file.uri} )} {(agent) => ( agent {agent.name} )} ) } function ShellMessage(props: { message: SessionMessageShell }) { const { theme } = useTheme() const dimensions = useTerminalDimensions() const output = createMemo(() => stripAnsi(props.message.output.trim())) const [expanded, setExpanded] = createSignal(false) const maxLines = 10 const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) const limited = createMemo(() => { if (expanded() || !collapsed().overflow) return output() return collapsed().output }) return ( setExpanded((prev) => !prev) : undefined} > $ {props.message.command} {limited()} {expanded() ? "Click to collapse" : "Click to expand"} ) } function CompactionMessage(props: { message: SessionMessageCompaction }) { const { theme, syntax } = useTheme() return ( {(summary) => ( )} ) } function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) { const { theme } = useTheme() const local = useLocal() return ( Switched agent to {Locale.titlecase(props.message.agent)} ) } function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) { const { theme } = useTheme() const model = createMemo(() => { const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" return `${props.message.model.providerID}/${props.message.model.id}${variant}` }) return ( Switched model to {model()} ) } function UnknownMessage(props: { message: SessionMessage }) { return } function AssistantMessage(props: { message: SessionMessageAssistant sessionID: string last: boolean syntax: SyntaxStyle subtleSyntax: SyntaxStyle start?: number }) { const { theme } = useTheme() const local = useLocal() const duration = createMemo(() => { if (!props.message.time.completed) return 0 return props.message.time.completed - (props.start ?? props.message.time.created) }) const model = createMemo(() => { const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" return `${props.message.model.providerID}/${props.message.model.id}${variant}` }) const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)) return ( <> {(part) => ( props.message.time.completed} /> )} {props.message.error} {Locale.titlecase(props.message.agent)} · {model()} · {Locale.duration(duration())} ) } function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) { const { theme } = useTheme() return ( ) } function AssistantReasoning(props: { part: SessionMessageAssistantReasoning subtleSyntax: SyntaxStyle completedAt: () => number | undefined }) { const { theme } = useTheme() const thinking = useThinkingMode() const [expanded, setExpanded] = createSignal(false) const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) const inMinimal = createMemo(() => thinking.mode() === "hide") // v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning // in the v2 SDK); we settle on parent-message completion instead. const isDone = createMemo(() => props.completedAt() !== undefined) const title = createMemo(() => reasoningTitle(content())) const toggle = () => { if (!inMinimal()) return setExpanded((prev) => !prev) } return ( {title() ? "▶ Thought: " + title() : "▶ Thought"} {title() ? "Thinking: " + title() : "Thinking"} ) } function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) { const input = createMemo(() => toolInputRecord(props.part.state.input)) const toolprops = { get input() { return input() }, get metadata() { return props.part.provider?.metadata ?? {} }, get output() { return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) }, sessionID: props.sessionID, part: props.part, } return ( ) } type ToolProps = { input: Record metadata: Record output?: string sessionID: string part: SessionMessageAssistantTool } function GenericTool(props: ToolProps) { const { theme } = useTheme() const dimensions = useTerminalDimensions() const output = createMemo(() => props.output?.trim() ?? "") const [expanded, setExpanded] = createSignal(false) const maxLines = 3 const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) const limited = createMemo(() => { if (expanded() || !collapsed().overflow) return output() return collapsed().output }) return ( {props.part.name} {input(props.input)} } > setExpanded((prev) => !prev) : undefined} > {limited()} {expanded() ? "Click to collapse" : "Click to expand"} ) } function InlineTool(props: { icon: string complete: unknown pending: string spinner?: boolean children: JSX.Element part: SessionMessageAssistantTool }) { const { theme } = useTheme() const renderer = useRenderer() const [margin, setMargin] = createSignal(0) const [hover, setHover] = createSignal(false) const [showError, setShowError] = createSignal(false) const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) const complete = createMemo(() => !!props.complete) const denied = createMemo(() => { const message = error() if (!message) return false return ( message.includes("QuestionRejectedError") || message.includes("rejected permission") || message.includes("specified a rule") || message.includes("user dismissed") ) }) const fg = createMemo(() => { if (error()) return theme.error if (complete()) return theme.textMuted return theme.text }) const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined)) return ( error() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { if (!error()) return if (renderer.getSelection()?.getSelectedText()) return setShowError((prev) => !prev) }} renderBefore={function () { const el = this as BoxRenderable const parent = el.parent if (!parent) return const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1] if (!previous) { setMargin(0) return } if (previous.id.startsWith("text")) setMargin(1) }} > {props.icon} ~ {props.children} {props.pending} {error()} ) } function BlockTool(props: { title: string children: JSX.Element part?: SessionMessageAssistantTool onClick?: () => void spinner?: boolean }) { const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) return ( props.onClick && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return props.onClick?.() }} flexShrink={0} > {props.title} } > {props.title.replace(/^# /, "")} {props.children} {error()} ) } function Bash(props: ToolProps) { const { theme } = useTheme() const dimensions = useTerminalDimensions() const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim())) const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part)) const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`) const [expanded, setExpanded] = createSignal(false) const maxLines = 10 const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) const limited = createMemo(() => { if (expanded() || !collapsed().overflow) return output() return collapsed().output }) return ( setExpanded((prev) => !prev) : undefined} > $ {command()} {limited()} {expanded() ? "Click to collapse" : "Click to expand"} {command()} ) } function Glob(props: ToolProps) { return ( Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} in {normalizePath(stringValue(props.input.path))} {(count) => ( <> ({count()} {count() === 1 ? "match" : "matches"}) )} ) } function Read(props: ToolProps) { const { theme } = useTheme() const loaded = createMemo(() => arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"), ) return ( <> Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "} {input(props.input, ["filePath"])} {(filepath) => ( ↳ Loaded {normalizePath(filepath)} )} ) } function Grep(props: ToolProps) { return ( Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} in {normalizePath(stringValue(props.input.path))} {(matches) => ( <> ({matches()} {matches() === 1 ? "match" : "matches"}) )} ) } function WebFetch(props: ToolProps) { return ( WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)} ) } function WebSearch(props: ToolProps) { const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} {(results) => <>({results()} results)} ) } function Write(props: ToolProps) { const { theme, syntax } = useTheme() const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") const content = createMemo(() => stringValue(props.input.content) ?? "") return ( Write {normalizePath(filePath())} ) } function Edit(props: ToolProps) { const { theme, syntax } = useTheme() const dimensions = useTerminalDimensions() const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") const diff = createMemo(() => stringValue(props.metadata.diff)) return ( {(diff) => ( 120 ? "split" : "unified"} filetype={filetype(filePath())} syntaxStyle={syntax()} showLineNumbers={true} width="100%" wrapMode="word" fg={theme.text} addedBg={theme.diffAddedBg} removedBg={theme.diffRemovedBg} contextBg={theme.diffContextBg} addedSignColor={theme.diffHighlightAdded} removedSignColor={theme.diffHighlightRemoved} lineNumberFg={theme.diffLineNumber} lineNumberBg={theme.diffContextBg} addedLineNumberBg={theme.diffAddedLineNumberBg} removedLineNumberBg={theme.diffRemovedLineNumberBg} /> )} Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })} ) } function ApplyPatch(props: ToolProps) { const { theme, syntax } = useTheme() const dimensions = useTerminalDimensions() const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : []))) const fileTitle = (file: Record) => { const type = stringValue(file.type) const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch" if (type === "delete") return "# Deleted " + relativePath if (type === "add") return "# Created " + relativePath if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath return "← Patched " + relativePath } return ( 0}> {(file) => ( -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"} } > {(patch) => ( 120 ? "split" : "unified"} filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))} syntaxStyle={syntax()} showLineNumbers={true} width="100%" wrapMode="word" fg={theme.text} addedBg={theme.diffAddedBg} removedBg={theme.diffRemovedBg} contextBg={theme.diffContextBg} addedSignColor={theme.diffHighlightAdded} removedSignColor={theme.diffHighlightRemoved} lineNumberFg={theme.diffLineNumber} lineNumberBg={theme.diffContextBg} addedLineNumberBg={theme.diffAddedLineNumberBg} removedLineNumberBg={theme.diffRemovedLineNumberBg} /> )} )} Patch ) } function TodoWrite(props: ToolProps) { const { theme } = useTheme() const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : []))) return ( 0 && props.part.state.status === "completed"}> {(todo) => ( {todoIcon(stringValue(todo.status))} {stringValue(todo.content)} )} Updating todos... ) } function Question(props: ToolProps) { const { theme } = useTheme() const questions = createMemo(() => arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])), ) const answers = createMemo(() => arrayValue(props.metadata.answers)) return ( 0}> {(question, index) => ( {stringValue(question.question)} {formatAnswer(answers()[index()])} )} Asked {questions().length} question{questions().length === 1 ? "" : "s"} ) } function Skill(props: ToolProps) { return ( Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}" ) } function Task(props: ToolProps) { const content = createMemo(() => { const description = stringValue(props.input.description) if (!description) return pendingInput(props.part) return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}` }) return ( {content()} ) } function Diagnostics(props: { diagnostics: unknown; filePath: string }) { const { theme } = useTheme() const errors = createMemo(() => { if (!isRecord(props.diagnostics)) return [] const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath] return arrayValue(value) .flatMap((item) => (isRecord(item) ? [item] : [])) .filter((diagnostic) => diagnostic.severity === 1) .slice(0, 3) }) return ( {(diagnostic) => Error {stringValue(diagnostic.message)}} ) } function toolOutput(content?: Array) { return (content ?? []) .map((item) => { if (item.type === "text") return item.text.trim() return `[file ${item.name ?? item.uri}]` }) .filter(Boolean) .join("\n") } function toolInputRecord(input: string | Record) { if (typeof input === "string") return {} return input } function pendingInput(part: SessionMessageAssistantTool) { if (part.state.status !== "pending") return "" return part.state.input.trim() } function toolComplete(part: SessionMessageAssistantTool) { if (part.state.status === "pending") return pendingInput(part) return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running" } function stringValue(value: unknown) { return typeof value === "string" ? value : undefined } function numberValue(value: unknown) { return typeof value === "number" ? value : undefined } function arrayValue(value: unknown): unknown[] { return Array.isArray(value) ? value : [] } function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } function input(input: Record, omit?: string[]) { const primitives = Object.entries(input).filter(([key, value]) => { if (omit?.includes(key)) return false return typeof value === "string" || typeof value === "number" || typeof value === "boolean" }) if (primitives.length === 0) return "" return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]` } function normalizePath(input?: string) { if (!input) return "" const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input) const relative = path.relative(process.cwd(), absolute) if (!relative) return "." if (!relative.startsWith("..")) return relative return absolute } function filetype(input?: string) { if (!input) return "none" const language = LANGUAGE_EXTENSIONS[path.extname(input)] if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" return language } function todoIcon(status?: string) { if (status === "completed") return "✓" if (status === "in_progress") return "~" if (status === "cancelled") return "✕" return "☐" } function formatAnswer(answer: unknown) { if (!Array.isArray(answer)) return "(no answer)" if (answer.length === 0) return "(no answer)" return answer.filter((item): item is string => typeof item === "string").join(", ") } const tui: TuiPlugin = async (api) => { api.route.register([ { name: route, render(input) { const sessionID = input.params?.sessionID if (typeof sessionID !== "string") { return Missing sessionID } return }, }, ]) api.keymap.registerLayer({ commands: [ { name: route, title: "View v2 session messages", category: "Debug", namespace: "palette", suggested: () => api.route.current.name === "session", enabled: () => api.route.current.name === "session", run() { const sessionID = currentSessionID(api) if (!sessionID) return api.route.navigate(route, { sessionID }) api.ui.dialog.clear() }, }, ], }) } const plugin: InternalTuiPlugin = { id, tui, } export default plugin