import { batch, createContext, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, useContext, } from "solid-js" import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" import { useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, MacOSScrollAccel, type ScrollAcceleration, TextAttributes, RGBA, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" import { BashTool } from "@/tool/bash" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "@tui/context/keybind" import { Header } from "./header" import { parsePatch } from "diff" import { useDialog } from "../../ui/dialog" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" import type { PromptInfo } from "../../component/prompt/history" import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import { Clipboard } from "../../util/clipboard" import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" addDefaultParsers(parsers.parsers) class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} tick(_now?: number): number { return this.speed } reset(): void {} } const context = createContext<{ width: number sessionID: string conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean showDetails: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType }>() function use() { const ctx = useContext(context) if (!ctx) throw new Error("useContext must be used within a Session component") return ctx } export function Session() { const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) const children = createMemo(() => { const parentID = session()?.parentID ?? session()?.id return sync.data.session .filter((x) => x.parentID === parentID || x.id === parentID) .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) }) const questions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.question[x.id] ?? []) }) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id }) const lastAssistant = createMemo(() => { return messages().findLast((x) => x.role === "assistant") }) const dimensions = useTerminalDimensions() const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide") const [sidebarOpen, setSidebarOpen] = createSignal(false) const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { if (session()?.parentID) return false if (sidebarOpen()) return true if (sidebar() === "auto" && wide()) return true return false }) const showTimestamps = createMemo(() => timestamps() === "show") const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) const scrollAcceleration = createMemo(() => { const tui = sync.data.config.tui if (tui?.scroll_acceleration?.enabled) { return new MacOSScrollAccel() } if (tui?.scroll_speed) { return new CustomSpeedScroll(tui.scroll_speed) } return new CustomSpeedScroll(3) }) createEffect(async () => { await sync.session .sync(route.sessionID) .then(() => { if (scroll) scroll.scrollBy(100_000) }) .catch((e) => { console.error(e) toast.show({ message: `Session not found: ${route.sessionID}`, variant: "error", }) return navigate({ type: "home" }) }) }) const toast = useToast() const sdk = useSDK() // Handle initial prompt from fork createEffect(() => { if (route.initialPrompt && prompt) { prompt.set(route.initialPrompt) } }) let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() // Allow exit when in child session (prompt is hidden) const exit = useExit() useKeyboard((evt) => { if (!session()?.parentID) return if (keybind.match("app_exit", evt)) { exit() } }) // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() const messagesList = messages() const scrollTop = scroll.y // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content const visibleMessages = children .filter((c) => { if (!c.id) return false const message = messagesList.find((m) => m.id === c.id) if (!message) return false // Check if message has valid non-synthetic, non-ignored text parts const parts = sync.data.part[message.id] if (!parts || !Array.isArray(parts)) return false return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored) }) .sort((a, b) => a.y - b.y) if (visibleMessages.length === 0) return null if (direction === "next") { // Find first message below current position return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null } // Find last message above current position return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null } // Helper: Scroll to message in direction or fallback to page scroll const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType) => { const targetID = findNextVisibleMessage(direction) if (!targetID) { scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height) dialog.clear() return } const child = scroll.getChildren().find((c) => c.id === targetID) if (child) scroll.scrollBy(child.y - scroll.y - 1) dialog.clear() } function toBottom() { setTimeout(() => { if (scroll) scroll.scrollTo(scroll.scrollHeight) }, 50) } const local = useLocal() function moveChild(direction: number) { if (children().length === 1) return let next = children().findIndex((x) => x.id === session()?.id) + direction if (next >= children().length) next = 0 if (next < 0) next = children().length - 1 if (children()[next]) { navigate({ type: "session", sessionID: children()[next].id, }) } } const command = useCommandDialog() command.register(() => [ ...(sync.data.config.share !== "disabled" ? [ { title: "Share session", value: "session.share", suggested: route.type === "session", keybind: "session_share" as const, disabled: !!session()?.share?.url, category: "Session", onSelect: async (dialog: any) => { await sdk.client.session .share({ sessionID: route.sessionID, }) .then((res) => Clipboard.copy(res.data!.share!.url).catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }), ), ) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) dialog.clear() }, }, ] : []), { title: "Rename session", value: "session.rename", keybind: "session_rename", category: "Session", onSelect: (dialog) => { dialog.replace(() => ) }, }, { title: "Jump to message", value: "session.timeline", keybind: "session_timeline", category: "Session", onSelect: (dialog) => { dialog.replace(() => ( { const child = scroll.getChildren().find((child) => { return child.id === messageID }) if (child) scroll.scrollBy(child.y - scroll.y - 1) }} sessionID={route.sessionID} setPrompt={(promptInfo) => prompt.set(promptInfo)} /> )) }, }, { title: "Fork from message", value: "session.fork", keybind: "session_fork", category: "Session", onSelect: (dialog) => { dialog.replace(() => ( { const child = scroll.getChildren().find((child) => { return child.id === messageID }) if (child) scroll.scrollBy(child.y - scroll.y - 1) }} sessionID={route.sessionID} /> )) }, }, { title: "Compact session", value: "session.compact", keybind: "session_compact", category: "Session", onSelect: (dialog) => { const selectedModel = local.model.current() if (!selectedModel) { toast.show({ variant: "warning", message: "Connect a provider to summarize this session", duration: 3000, }) return } sdk.client.session.summarize({ sessionID: route.sessionID, modelID: selectedModel.modelID, providerID: selectedModel.providerID, }) dialog.clear() }, }, { title: "Unshare session", value: "session.unshare", keybind: "session_unshare", disabled: !session()?.share?.url, category: "Session", onSelect: async (dialog) => { await sdk.client.session .unshare({ sessionID: route.sessionID, }) .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" })) dialog.clear() }, }, { title: "Undo previous message", value: "session.undo", keybind: "messages_undo", category: "Session", onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) const revert = session()?.revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return sdk.client.session .revert({ sessionID: route.sessionID, messageID: message.id, }) .then(() => { toBottom() }) const parts = sync.data.part[message.id] prompt.set( parts.reduce( (agg, part) => { if (part.type === "text") { if (!part.synthetic) agg.input += part.text } if (part.type === "file") agg.parts.push(part) return agg }, { input: "", parts: [] as PromptInfo["parts"] }, ), ) dialog.clear() }, }, { title: "Redo", value: "session.redo", keybind: "messages_redo", disabled: !session()?.revert?.messageID, category: "Session", onSelect: (dialog) => { dialog.clear() const messageID = session()?.revert?.messageID if (!messageID) return const message = messages().find((x) => x.role === "user" && x.id > messageID) if (!message) { sdk.client.session.unrevert({ sessionID: route.sessionID, }) prompt.set({ input: "", parts: [] }) return } sdk.client.session.revert({ sessionID: route.sessionID, messageID: message.id, }) }, }, { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", value: "session.sidebar.toggle", keybind: "sidebar_toggle", category: "Session", onSelect: (dialog) => { batch(() => { const isVisible = sidebarVisible() setSidebar(() => (isVisible ? "hide" : "auto")) setSidebarOpen(!isVisible) }) dialog.clear() }, }, { title: "Toggle code concealment", value: "session.toggle.conceal", keybind: "messages_toggle_conceal" as any, category: "Session", onSelect: (dialog) => { setConceal((prev) => !prev) dialog.clear() }, }, { title: showTimestamps() ? "Hide timestamps" : "Show timestamps", value: "session.toggle.timestamps", category: "Session", onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() }, }, { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", category: "Session", onSelect: (dialog) => { setShowThinking((prev) => !prev) dialog.clear() }, }, { title: "Toggle diff wrapping", value: "session.toggle.diffwrap", category: "Session", onSelect: (dialog) => { setDiffWrapMode((prev) => (prev === "word" ? "none" : "word")) dialog.clear() }, }, { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", keybind: "tool_details", category: "Session", onSelect: (dialog) => { setShowDetails((prev) => !prev) dialog.clear() }, }, { title: "Toggle session scrollbar", value: "session.toggle.scrollbar", keybind: "scrollbar_toggle", category: "Session", onSelect: (dialog) => { setShowScrollbar((prev) => !prev) dialog.clear() }, }, { title: animationsEnabled() ? "Disable animations" : "Enable animations", value: "session.toggle.animations", category: "Session", onSelect: (dialog) => { setAnimationsEnabled((prev) => !prev) dialog.clear() }, }, { title: "Page up", value: "session.page.up", keybind: "messages_page_up", category: "Session", disabled: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 2) dialog.clear() }, }, { title: "Page down", value: "session.page.down", keybind: "messages_page_down", category: "Session", disabled: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 2) dialog.clear() }, }, { title: "Half page up", value: "session.half.page.up", keybind: "messages_half_page_up", category: "Session", disabled: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 4) dialog.clear() }, }, { title: "Half page down", value: "session.half.page.down", keybind: "messages_half_page_down", category: "Session", disabled: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 4) dialog.clear() }, }, { title: "First message", value: "session.first", keybind: "messages_first", category: "Session", disabled: true, onSelect: (dialog) => { scroll.scrollTo(0) dialog.clear() }, }, { title: "Last message", value: "session.last", keybind: "messages_last", category: "Session", disabled: true, onSelect: (dialog) => { scroll.scrollTo(scroll.scrollHeight) dialog.clear() }, }, { title: "Jump to last user message", value: "session.messages_last_user", keybind: "messages_last_user", category: "Session", onSelect: () => { const messages = sync.data.message[route.sessionID] if (!messages || !messages.length) return // Find the most recent user message with non-ignored, non-synthetic text parts for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (!message || message.role !== "user") continue const parts = sync.data.part[message.id] if (!parts || !Array.isArray(parts)) continue const hasValidTextPart = parts.some( (part) => part && part.type === "text" && !part.synthetic && !part.ignored, ) if (hasValidTextPart) { const child = scroll.getChildren().find((child) => { return child.id === message.id }) if (child) scroll.scrollBy(child.y - scroll.y - 1) break } } }, }, { title: "Next message", value: "session.message.next", keybind: "messages_next", category: "Session", disabled: true, onSelect: (dialog) => scrollToMessage("next", dialog), }, { title: "Previous message", value: "session.message.previous", keybind: "messages_previous", category: "Session", disabled: true, onSelect: (dialog) => scrollToMessage("prev", dialog), }, { title: "Copy last assistant message", value: "messages.copy", keybind: "messages_copy", category: "Session", onSelect: (dialog) => { const revertID = session()?.revert?.messageID const lastAssistantMessage = messages().findLast( (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), ) if (!lastAssistantMessage) { toast.show({ message: "No assistant messages found", variant: "error" }) dialog.clear() return } const parts = sync.data.part[lastAssistantMessage.id] ?? [] const textParts = parts.filter((part) => part.type === "text") if (textParts.length === 0) { toast.show({ message: "No text parts found in last assistant message", variant: "error" }) dialog.clear() return } const text = textParts .map((part) => part.text) .join("\n") .trim() if (!text) { toast.show({ message: "No text content found in last assistant message", variant: "error", }) dialog.clear() return } const base64 = Buffer.from(text).toString("base64") const osc52 = `\x1b]52;c;${base64}\x07` const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 /* @ts-expect-error */ renderer.writeOut(finalOsc52) Clipboard.copy(text) .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) dialog.clear() }, }, { title: "Copy session transcript", value: "session.copy", keybind: "session_copy", category: "Session", onSelect: async (dialog) => { try { const sessionData = session() if (!sessionData) return const sessionMessages = messages() const transcript = formatTranscript( sessionData, sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), { thinking: showThinking(), toolDetails: showDetails(), assistantMetadata: showAssistantMetadata(), }, ) await Clipboard.copy(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch (error) { toast.show({ message: "Failed to copy session transcript", variant: "error" }) } dialog.clear() }, }, { title: "Export session transcript", value: "session.export", keybind: "session_export", category: "Session", onSelect: async (dialog) => { try { const sessionData = session() if (!sessionData) return const sessionMessages = messages() const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` const options = await DialogExportOptions.show( dialog, defaultFilename, showThinking(), showDetails(), showAssistantMetadata(), false, ) if (options === null) return const transcript = formatTranscript( sessionData, sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })), { thinking: options.thinking, toolDetails: options.toolDetails, assistantMetadata: options.assistantMetadata, }, ) if (options.openWithoutSaving) { // Just open in editor without saving await Editor.open({ value: transcript, renderer }) } else { const exportDir = process.cwd() const filename = options.filename.trim() const filepath = path.join(exportDir, filename) await Bun.write(filepath, transcript) // Open with EDITOR if available const result = await Editor.open({ value: transcript, renderer }) if (result !== undefined) { await Bun.write(filepath, result) } toast.show({ message: `Session exported to ${filename}`, variant: "success" }) } } catch (error) { toast.show({ message: "Failed to export session", variant: "error" }) } dialog.clear() }, }, { title: "Next child session", value: "session.child.next", keybind: "session_child_cycle", category: "Session", disabled: true, onSelect: (dialog) => { moveChild(1) dialog.clear() }, }, { title: "Previous child session", value: "session.child.previous", keybind: "session_child_cycle_reverse", category: "Session", disabled: true, onSelect: (dialog) => { moveChild(-1) dialog.clear() }, }, { title: "Go to parent session", value: "session.parent", keybind: "session_parent", category: "Session", disabled: true, onSelect: (dialog) => { const parentID = session()?.parentID if (parentID) { navigate({ type: "session", sessionID: parentID, }) } dialog.clear() }, }, ]) const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) const revertDiffFiles = createMemo(() => { const diffText = revertInfo()?.diff ?? "" if (!diffText) return [] try { const patches = parsePatch(diffText) return patches.map((patch) => { const filename = patch.newFileName || patch.oldFileName || "unknown" const cleanFilename = filename.replace(/^[ab]\//, "") return { filename: cleanFilename, additions: patch.hunks.reduce( (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0, ), deletions: patch.hunks.reduce( (sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0, ), } }) } catch (error) { return [] } }) const revertRevertedMessages = createMemo(() => { const messageID = revertMessageID() if (!messageID) return [] return messages().filter((x) => x.id >= messageID && x.role === "user") }) const revert = createMemo(() => { const info = revertInfo() if (!info) return if (!info.messageID) return return { messageID: info.messageID, reverted: revertRevertedMessages(), diff: info.diff, diffFiles: revertDiffFiles(), } }) const dialog = useDialog() const renderer = useRenderer() // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) return (
(scroll = r)} viewportOptions={{ paddingRight: showScrollbar() ? 1 : 0, }} verticalScrollbarOptions={{ paddingLeft: 1, visible: showScrollbar(), trackOptions: { backgroundColor: theme.backgroundElement, foregroundColor: theme.border, }, }} stickyScroll={true} stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} > {(message, index) => ( {(function () { const command = useCommandDialog() const [hover, setHover] = createSignal(false) const dialog = useDialog() const handleUnrevert = async () => { const confirmed = await DialogConfirm.show( dialog, "Confirm Redo", "Are you sure you want to restore the reverted messages?", ) if (confirmed) { command.trigger("session.redo") } } return ( setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={handleUnrevert} marginTop={1} flexShrink={0} border={["left"]} customBorderChars={SplitBorder.customBorderChars} borderColor={theme.backgroundPanel} > {revert()!.reverted.length} message reverted {keybind.print("messages_redo")} or /redo to restore {(file) => ( {file.filename} 0}> +{file.additions} 0}> -{file.deletions} )} ) })()} = revert()!.messageID}> <> { if (renderer.getSelection()?.getSelectedText()) return dialog.replace(() => ( prompt.set(promptInfo)} /> )) }} message={message as UserMessage} parts={sync.data.part[message.id] ?? []} pending={pending()} /> )} 0}> 0}> { prompt = r promptRef.set(r) // Apply initial prompt when prompt component mounts (e.g., from fork) if (route.initialPrompt) { r.set(route.initialPrompt) } }} disabled={permissions().length > 0 || questions().length > 0} onSubmit={() => { toBottom() }} sessionID={route.sessionID} /> ) } const MIME_BADGE: Record = { "text/plain": "txt", "image/png": "img", "image/jpeg": "img", "image/gif": "img", "image/webp": "img", "application/pdf": "pdf", "application/x-directory": "dir", } function UserMessage(props: { message: UserMessage parts: Part[] onMouseUp: () => void index: number pending?: string }) { const ctx = use() const local = useLocal() const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0]) const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : []))) const sync = useSync() const { theme } = useTheme() const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent))) const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) return ( <> { setHover(true) }} onMouseOut={() => { setHover(false) }} onMouseUp={props.onMouseUp} paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > {text()?.text} {(file) => { const bg = createMemo(() => { if (file.mime.startsWith("image/")) return theme.accent if (file.mime === "application/pdf") return theme.primary return theme.secondary }) return ( {MIME_BADGE[file.mime] ?? file.mime} {file.filename} ) }} {Locale.todayTimeOrDateTime(props.message.time.created)} } > QUEUED ) } function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { const local = useLocal() const { theme } = useTheme() const sync = useSync() const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) const final = createMemo(() => { return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) }) const duration = createMemo(() => { if (!final()) return 0 if (!props.message.time.completed) return 0 const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID) if (!user || !user.time) return 0 return props.message.time.completed - user.time.created }) return ( <> {(part, index) => { const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) return ( ) }} {props.message.error?.data.message} ▣{" "} {" "} {Locale.titlecase(props.message.mode)} · {props.message.modelID} · {Locale.duration(duration())} · interrupted ) } const PART_MAPPING = { text: TextPart, tool: ToolPart, reasoning: ReasoningPart, } function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) { const { theme, subtleSyntax } = useTheme() const ctx = use() const content = createMemo(() => { // Filter out redacted reasoning chunks from OpenRouter // OpenRouter sends encrypted reasoning data that appears as [REDACTED] return props.part.text.replace("[REDACTED]", "").trim() }) return ( ) } function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() const { theme, syntax } = useTheme() return ( ) } // Pending messages moved to individual tool pending functions function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) { const ctx = use() const sync = useSync() // Hide tool if showDetails is false and tool completed successfully const shouldHide = createMemo(() => { if (ctx.showDetails()) return false if (props.part.state.status !== "completed") return false return true }) const toolprops = { get metadata() { return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {}) }, get input() { return props.part.state.input ?? {} }, get output() { return props.part.state.status === "completed" ? props.part.state.output : undefined }, get permission() { const permissions = sync.data.permission[props.message.sessionID] ?? [] const permissionIndex = permissions.findIndex((x) => x.tool?.callID === props.part.callID) return permissions[permissionIndex] }, get tool() { return props.part.tool }, get part() { return props.part }, } return ( ) } type ToolProps = { input: Partial> metadata: Partial> permission: Record tool: string output?: string part: ToolPart } function GenericTool(props: ToolProps) { return ( {props.tool} {input(props.input)} ) } function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) { const { theme } = useTheme() return ( ~ {props.fallback}} when={props.when}> {props.icon} {props.children} ) } function InlineTool(props: { icon: string iconColor?: RGBA complete: any pending: string children: JSX.Element part: ToolPart }) { const [margin, setMargin] = createSignal(0) const { theme } = useTheme() const ctx = use() const sync = useSync() const permission = createMemo(() => { const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID if (!callID) return false return callID === props.part.callID }) const fg = createMemo(() => { if (permission()) return theme.warning if (props.complete) return theme.textMuted return theme.text }) const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) const denied = createMemo( () => error()?.includes("rejected permission") || error()?.includes("specified a rule") || error()?.includes("user dismissed"), ) return ( 1) { setMargin(1) return } const children = parent.getChildren() const index = children.indexOf(el) const previous = children[index - 1] if (!previous) { setMargin(0) return } if (previous.height > 1 || previous.id.startsWith("text-")) { setMargin(1) return } }} > ~ {props.pending}} when={props.complete}> {props.icon} {props.children} {error()} ) } function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) { const { theme } = useTheme() const renderer = useRenderer() const [hover, setHover] = createSignal(false) const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) return ( props.onClick && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return props.onClick?.() }} > {props.title} {props.children} {error()} ) } function Bash(props: ToolProps) { const { theme } = useTheme() const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) const lines = createMemo(() => output().split("\n")) const overflow = createMemo(() => lines().length > 10) const limited = createMemo(() => { if (expanded() || !overflow()) return output() return [...lines().slice(0, 10), "…"].join("\n") }) return ( setExpanded((prev) => !prev) : undefined} > $ {props.input.command} {limited()} {expanded() ? "Click to collapse" : "Click to expand"} {props.input.command} ) } function Write(props: ToolProps) { const { theme, syntax } = useTheme() const code = createMemo(() => { if (!props.input.content) return "" return props.input.content }) const diagnostics = createMemo(() => { const filePath = Filesystem.normalizePath(props.input.filePath ?? "") return props.metadata.diagnostics?.[filePath] ?? [] }) return ( {(diagnostic) => ( Error [{diagnostic.range.start.line}:{diagnostic.range.start.character}]: {diagnostic.message} )} Write {normalizePath(props.input.filePath!)} ) } function Glob(props: ToolProps) { return ( Glob "{props.input.pattern}" in {normalizePath(props.input.path)} ({props.metadata.count} matches) ) } function Read(props: ToolProps) { return ( Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} ) } function Grep(props: ToolProps) { return ( Grep "{props.input.pattern}" in {normalizePath(props.input.path)} ({props.metadata.matches} matches) ) } function List(props: ToolProps) { const dir = createMemo(() => { if (props.input.path) { return normalizePath(props.input.path) } return "" }) return ( List {dir()} ) } function WebFetch(props: ToolProps) { return ( WebFetch {(props.input as any).url} ) } function CodeSearch(props: ToolProps) { const input = props.input as any const metadata = props.metadata as any return ( Exa Code Search "{input.query}" ({metadata.results} results) ) } function WebSearch(props: ToolProps) { const input = props.input as any const metadata = props.metadata as any return ( Exa Web Search "{input.query}" ({metadata.numResults} results) ) } function Task(props: ToolProps) { const { theme } = useTheme() const keybind = useKeybind() const { navigate } = useRoute() const local = useLocal() const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending")) const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown")) return ( navigate({ type: "session", sessionID: props.metadata.sessionId! }) : undefined } part={props.part} > {props.input.description} ({props.metadata.summary?.length} toolcalls) └ {Locale.titlecase(current()!.tool)}{" "} {current()!.state.status === "completed" ? current()!.state.title : ""} {keybind.print("session_child_cycle")} view subagents {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task " {props.input.description}" ) } function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() const view = createMemo(() => { const diffStyle = ctx.sync.data.config.tui?.diff_style if (diffStyle === "stacked") return "unified" // Default to "auto" behavior return ctx.width > 120 ? "split" : "unified" }) const ft = createMemo(() => filetype(props.input.filePath)) const diffContent = createMemo(() => props.metadata.diff) const diagnostics = createMemo(() => { const filePath = Filesystem.normalizePath(props.input.filePath ?? "") const arr = props.metadata.diagnostics?.[filePath] ?? [] return arr.filter((x) => x.severity === 1).slice(0, 3) }) return ( {(diagnostic) => ( Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]{" "} {diagnostic.message} )} Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} ) } function Patch(props: ToolProps) { const { theme } = useTheme() return ( {props.output?.trim()} Patch ) } function TodoWrite(props: ToolProps) { return ( {(todo) => } Updating todos... ) } function Question(props: ToolProps) { const { theme } = useTheme() const count = createMemo(() => props.input.questions?.length ?? 0) function format(answer?: string[]) { if (!answer?.length) return "(no answer)" return answer.join(", ") } return ( {(q, i) => ( {q.question} {format(props.metadata.answers?.[i()])} )} Asked {count()} question{count() !== 1 ? "s" : ""} ) } function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { return path.relative(process.cwd(), input) || "." } return input } function input(input: Record, omit?: string[]): 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 filetype(input?: string) { if (!input) return "none" const ext = path.extname(input) const language = LANGUAGE_EXTENSIONS[ext] if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" return language }