import { batch, createContext, createEffect, createMemo, createSignal, For, Match, on, onMount, 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 { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" import type { AssistantMessage, Part, Provider, 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 { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" import type { DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" 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 { SubagentFooter } from "./subagent-footer.tsx" import { Flag } from "@/flag/flag" 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 { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "../../plugin" addDefaultParsers(parsers.parsers) const context = createContext<{ width: number sessionID: string conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean showDetails: () => boolean showGenericToolOutput: () => boolean diffWrapMode: () => "word" | "none" providers: () => ReadonlyMap sync: ReturnType tui: 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 tuiConfig = useTuiConfig() 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 visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) 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", "auto") 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", true) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) 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 providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) 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 let seeded = false let lastSwitch: string | undefined = undefined sdk.event.on("message.part.updated", (evt) => { const part = evt.properties.part if (part.type !== "tool") return if (part.sessionID !== route.sessionID) return if (part.state.status !== "completed") return if (part.id === lastSwitch) return if (part.tool === "plan_exit") { local.agent.set("build") lastSwitch = part.id } else if (part.tool === "plan_enter") { local.agent.set("plan") lastSwitch = part.id } }) let scroll: ScrollBoxRenderable let prompt: PromptRef | undefined const bind = (r: PromptRef | undefined) => { prompt = r promptRef.set(r) if (seeded || !route.initialPrompt || !r) return seeded = true r.set(route.initialPrompt) } const keybind = useKeybind() const dialog = useDialog() const renderer = useRenderer() // Allow exit when in child session (prompt is hidden) const exit = useExit() createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) const pad = (text: string) => text.padEnd(10, " ") const weak = (text: string) => UI.Style.TEXT_DIM + pad(text) + UI.Style.TEXT_NORMAL const logo = UI.logo(" ").split(/\r?\n/) return exit.message.set( [ `${logo[0] ?? ""}`, `${logo[1] ?? ""}`, `${logo[2] ?? ""}`, `${logo[3] ?? ""}`, ``, ` ${weak("Session")}${UI.Style.TEXT_NORMAL_BOLD}${title}${UI.Style.TEXT_NORMAL}`, ` ${weak("Continue")}${UI.Style.TEXT_NORMAL_BOLD}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`, ``, ].join("\n"), ) }) 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.isDestroyed) return scroll.scrollTo(scroll.scrollHeight) }, 50) } const local = useLocal() function moveFirstChild() { if (children().length === 1) return const next = children().find((x) => !!x.parentID) if (next) { navigate({ type: "session", sessionID: next.id, }) } } function moveChild(direction: number) { if (children().length === 1) return const sessions = children().filter((x) => !!x.parentID) let next = sessions.findIndex((x) => x.id === session()?.id) - direction if (next >= sessions.length) next = 0 if (next < 0) next = sessions.length - 1 if (sessions[next]) { navigate({ type: "session", sessionID: sessions[next].id, }) } } function childSessionHandler(func: (dialog: DialogContext) => void) { return (dialog: DialogContext) => { if (!session()?.parentID || dialog.stack.length > 0) return func(dialog) } } const command = useCommandDialog() command.register(() => [ { title: session()?.share?.url ? "Copy share link" : "Share session", value: "session.share", suggested: route.type === "session", keybind: "session_share", category: "Session", enabled: sync.data.config.share !== "disabled", slash: { name: "share", }, onSelect: async (dialog) => { const copy = (url: string) => Clipboard.copy(url) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) const url = session()?.share?.url if (url) { await copy(url) dialog.clear() return } if (!kv.get("share_consent", false)) { const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?") if (ok !== true) return kv.set("share_consent", true) } await sdk.client.session .share({ sessionID: route.sessionID, }) .then((res) => copy(res.data!.share!.url)) .catch((error) => { toast.show({ message: error instanceof Error ? error.message : "Failed to share session", variant: "error", }) }) dialog.clear() }, }, { title: "Rename session", value: "session.rename", keybind: "session_rename", category: "Session", slash: { name: "rename", }, onSelect: (dialog) => { dialog.replace(() => ) }, }, { title: "Jump to message", value: "session.timeline", keybind: "session_timeline", category: "Session", slash: { name: "timeline", }, 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", slash: { name: "fork", }, 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", slash: { name: "compact", aliases: ["summarize"], }, 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", category: "Session", enabled: !!session()?.share?.url, slash: { name: "unshare", }, onSelect: async (dialog) => { await sdk.client.session .unshare({ sessionID: route.sessionID, }) .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) .catch((error) => { toast.show({ message: error instanceof Error ? error.message : "Failed to unshare session", variant: "error", }) }) dialog.clear() }, }, { title: "Undo previous message", value: "session.undo", keybind: "messages_undo", category: "Session", slash: { name: "undo", }, 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", category: "Session", enabled: !!session()?.revert?.messageID, slash: { name: "redo", }, 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: conceal() ? "Disable code concealment" : "Enable 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", slash: { name: "timestamps", aliases: ["toggle-timestamps"], }, onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() }, }, { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", keybind: "display_thinking", category: "Session", slash: { name: "thinking", aliases: ["toggle-thinking"], }, onSelect: (dialog) => { setShowThinking((prev) => !prev) 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: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output", value: "session.toggle.generic_tool_output", category: "Session", onSelect: (dialog) => { setShowGenericToolOutput((prev) => !prev) dialog.clear() }, }, { title: "Page up", value: "session.page.up", keybind: "messages_page_up", category: "Session", hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 2) dialog.clear() }, }, { title: "Page down", value: "session.page.down", keybind: "messages_page_down", category: "Session", hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 2) dialog.clear() }, }, { title: "Line up", value: "session.line.up", keybind: "messages_line_up", category: "Session", disabled: true, onSelect: (dialog) => { scroll.scrollBy(-1) dialog.clear() }, }, { title: "Line down", value: "session.line.down", keybind: "messages_line_down", category: "Session", disabled: true, onSelect: (dialog) => { scroll.scrollBy(1) dialog.clear() }, }, { title: "Half page up", value: "session.half.page.up", keybind: "messages_half_page_up", category: "Session", hidden: 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", hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 4) dialog.clear() }, }, { title: "First message", value: "session.first", keybind: "messages_first", category: "Session", hidden: true, onSelect: (dialog) => { scroll.scrollTo(0) dialog.clear() }, }, { title: "Last message", value: "session.last", keybind: "messages_last", category: "Session", hidden: 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", hidden: true, 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", hidden: true, onSelect: (dialog) => scrollToMessage("next", dialog), }, { title: "Previous message", value: "session.message.previous", keybind: "messages_previous", category: "Session", hidden: 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 } 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", category: "Session", slash: { name: "copy", }, 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(), providers: sync.data.provider, }, ) 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", slash: { name: "export", }, 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, providers: sync.data.provider, }, ) 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 Filesystem.write(filepath, transcript) // Open with EDITOR if available const result = await Editor.open({ value: transcript, renderer }) if (result !== undefined) { await Filesystem.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: "Go to child session", value: "session.child.first", keybind: "session_child_first", category: "Session", hidden: true, onSelect: (dialog) => { moveFirstChild() dialog.clear() }, }, { title: "Go to parent session", value: "session.parent", keybind: "session_parent", category: "Session", hidden: true, enabled: !!session()?.parentID, onSelect: childSessionHandler((dialog) => { const parentID = session()?.parentID if (parentID) { navigate({ type: "session", sessionID: parentID, }) } dialog.clear() }), }, { title: "Next child session", value: "session.child.next", keybind: "session_child_cycle", category: "Session", hidden: true, enabled: !!session()?.parentID, onSelect: childSessionHandler((dialog) => { moveChild(1) dialog.clear() }), }, { title: "Previous child session", value: "session.child.previous", keybind: "session_child_cycle_reverse", category: "Session", hidden: true, enabled: !!session()?.parentID, onSelect: childSessionHandler((dialog) => { moveChild(-1) 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(), } }) // 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}> { toBottom() }} sessionID={route.sessionID} right={} /> ) } 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 { theme } = useTheme() const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) const color = createMemo(() => local.agent.color(props.message.agent)) const queuedFg = createMemo(() => selectedForeground(theme, color())) 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 ctx = use() const local = useLocal() const { theme } = useTheme() const sync = useSync() const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID)) 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 }) const keybind = useKeybind() return ( <> {(part, index) => { const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) return ( ) }} x.type === "tool" && x.tool === "task")}> {keybind.print("session_child_first")} view subagents {props.message.error?.data.message} ▣{" "} {" "} {Locale.titlecase(props.message.mode)} · {model()} · {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) { const { theme } = useTheme() const ctx = use() const output = createMemo(() => props.output?.trim() ?? "") const [expanded, setExpanded] = createSignal(false) const lines = createMemo(() => output().split("\n")) const maxLines = 3 const overflow = createMemo(() => lines().length > maxLines) const limited = createMemo(() => { if (expanded() || !overflow()) return output() return [...lines().slice(0, maxLines), "…"].join("\n") }) return ( {props.tool} {input(props.input)} } > setExpanded((prev) => !prev) : undefined} > {limited()} {expanded() ? "Click to collapse" : "Click to expand"} ) } function InlineTool(props: { icon: string iconColor?: RGBA complete: any pending: string spinner?: boolean children: JSX.Element part: ToolPart onClick?: () => void }) { const [margin, setMargin] = createSignal(0) const { theme } = useTheme() const ctx = use() const sync = useSync() const renderer = useRenderer() const [hover, setHover] = createSignal(false) 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 (hover() && props.onClick) return theme.text 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("QuestionRejectedError") || error()?.includes("rejected permission") || error()?.includes("specified a rule") || error()?.includes("user dismissed"), ) return ( props.onClick && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return props.onClick?.() }} renderBefore={function () { const el = this as BoxRenderable const parent = el.parent if (!parent) { return } if (el.height > 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 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 : undefined)) return ( props.onClick && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return props.onClick?.() }} > {props.title} } > {props.title.replace(/^# /, "")} {props.children} {error()} ) } function Bash(props: ToolProps) { const { theme } = useTheme() const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") 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") }) const workdirDisplay = createMemo(() => { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined const base = sync.data.path.directory if (!base) return undefined const absolute = path.resolve(base, workdir) if (absolute === base) return undefined const home = Global.Path.home if (!home) return absolute const match = absolute === home || absolute.startsWith(home + path.sep) return match ? absolute.replace(home, "~") : absolute }) const title = createMemo(() => { const desc = props.input.description ?? "Shell" const wd = workdirDisplay() if (!wd) return `# ${desc}` if (desc.includes(wd)) return `# ${desc}` return `# ${desc} in ${wd}` }) 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 }) return ( Write {normalizePath(props.input.filePath!)} ) } function Glob(props: ToolProps) { return ( Glob "{props.input.pattern}" in {normalizePath(props.input.path)} ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) ) } function Read(props: ToolProps) { const { theme } = useTheme() const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] if (props.part.state.time.compacted) return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) return ( <> Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} {(filepath) => ( ↳ Loaded {normalizePath(filepath)} )} ) } function Grep(props: ToolProps) { return ( Grep "{props.input.pattern}" in {normalizePath(props.input.path)} ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "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 { navigate } = useRoute() const sync = useSync() onMount(() => { if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length) sync.session.sync(props.metadata.sessionId) }) const messages = createMemo(() => sync.data.message[props.metadata.sessionId ?? ""] ?? []) const tools = createMemo(() => { return messages().flatMap((msg) => (sync.data.part[msg.id] ?? []) .filter((part): part is ToolPart => part.type === "tool") .map((part) => ({ tool: part.tool, state: part.state })), ) }) const current = createMemo(() => tools().findLast((x) => (x.state as any).title)) const isRunning = createMemo(() => props.part.state.status === "running") const duration = createMemo(() => { const first = messages().find((x) => x.role === "user")?.time.created const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed if (!first || !assistant) return 0 return assistant - first }) const content = createMemo(() => { if (!props.input.description) return "" let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${props.input.description}`] if (isRunning() && tools().length > 0) { // content[0] += ` · ${tools().length} toolcalls` if (current()) content.push(`↳ ${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`) else content.push(`↳ ${tools().length} toolcalls`) } if (props.part.state.status === "completed") { content.push(`└ ${tools().length} toolcalls · ${Locale.duration(duration())}`) } return content.join("\n") }) return ( { if (props.metadata.sessionId) { navigate({ type: "session", sessionID: props.metadata.sessionId }) } }} > {content()} ) } function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() const view = createMemo(() => { const diffStyle = ctx.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) return ( Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} ) } function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() const files = createMemo(() => props.metadata.files ?? []) const view = createMemo(() => { const diffStyle = ctx.tui.diff_style if (diffStyle === "stacked") return "unified" return ctx.width > 120 ? "split" : "unified" }) function Diff(p: { diff: string; filePath: string }) { return ( ) } function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { if (file.type === "delete") return "# Deleted " + file.relativePath if (file.type === "add") return "# Created " + file.relativePath if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath return "← Patched " + file.relativePath } return ( 0}> {(file) => ( -{file.deletions} line{file.deletions !== 1 ? "s" : ""} } > )} 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 Skill(props: ToolProps) { return ( Skill "{props.input.name}" ) } function Diagnostics(props: { diagnostics?: Record[]>; filePath: string }) { const { theme } = useTheme() const errors = createMemo(() => { const normalized = Filesystem.normalizePath(props.filePath) const arr = props.diagnostics?.[normalized] ?? [] 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} )} ) } function normalizePath(input?: string) { if (!input) return "" const cwd = process.cwd() const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) const relative = path.relative(cwd, absolute) if (!relative) return "." if (!relative.startsWith("..")) return relative // outside cwd - use absolute return absolute } 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 }