import { createContext, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, useContext, type Component, } 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, RGBA, type ScrollAcceleration, } 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 { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, 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 { DialogMessage } from "./dialog-message" import type { PromptInfo } from "../../component/prompt/history" import { iife } from "@/util/iife" import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogPrompt } from "@tui/ui/dialog-prompt" 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 { Filesystem } from "@/util/filesystem" import { DialogSubagent } from "./dialog-subagent.tsx" 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 conceal: () => boolean showThinking: () => boolean showTimestamps: () => boolean usernameVisible: () => boolean showDetails: () => boolean userMessageMarkdown: () => 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 messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) 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] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto")) const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true)) const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show") const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const wide = createMemo(() => dimensions().width > 120) const tall = createMemo(() => dimensions().height > 40) const sidebarVisible = createMemo(() => { if (session()?.parentID) return false if (sidebar() === "show") return true if (sidebar() === "auto" && wide()) return true return false }) const sidebarOverlay = createMemo(() => sidebarVisible() && !wide()) const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() && !sidebarOverlay() ? 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) } }) // Auto-navigate to whichever session currently needs permission input createEffect(() => { const currentSession = session() if (!currentSession) return const currentPermissions = permissions() let targetID = currentPermissions.length > 0 ? currentSession.id : undefined if (!targetID) { const child = sync.data.session.find( (x) => x.parentID === currentSession.id && (sync.data.permission[x.id]?.length ?? 0) > 0, ) if (child) targetID = child.id } if (targetID && targetID !== currentSession.id) { navigate({ type: "session", sessionID: targetID, }) } }) let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() // 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() } useKeyboard((evt) => { if (dialog.stack.length > 0) return const first = permissions()[0] if (first) { const response = iife(() => { if (evt.ctrl || evt.meta) return if (evt.name === "return") return "once" if (evt.name === "a") return "always" if (evt.name === "d") return "reject" if (evt.name === "escape") return "reject" return }) if (response) { sdk.client.permission.respond({ permissionID: first.id, sessionID: route.sessionID, response: response, }) } } }) function toBottom() { setTimeout(() => { if (scroll) scroll.scrollTo(scroll.scrollHeight) }, 50) } const local = useLocal() function moveChild(direction: number) { const parentID = session()?.parentID ?? session()?.id let children = 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)) 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) => { setSidebar((prev) => { if (prev === "auto") return sidebarVisible() ? "hide" : "show" if (prev === "show") return "hide" return "show" }) if (sidebar() === "show") kv.set("sidebar", "auto") if (sidebar() === "hide") kv.set("sidebar", "hide") dialog.clear() }, }, { title: usernameVisible() ? "Hide username" : "Show username", value: "session.username_visible.toggle", keybind: "username_toggle", category: "Session", onSelect: (dialog) => { setUsernameVisible((prev) => { const next = !prev kv.set("username_visible", next) return next }) 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) => { setShowTimestamps((prev) => { const next = !prev kv.set("timestamps", next ? "show" : "hide") return next }) dialog.clear() }, }, { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", category: "Session", onSelect: (dialog) => { setShowThinking((prev) => { const next = !prev kv.set("thinking_visibility", next) return next }) 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) => { const newValue = !showDetails() setShowDetails(newValue) kv.set("tool_details_visibility", newValue) dialog.clear() }, }, { title: "Toggle session scrollbar", value: "session.toggle.scrollbar", keybind: "scrollbar_toggle", category: "Session", onSelect: (dialog) => { setShowScrollbar((prev) => { const next = !prev kv.set("scrollbar_visible", next) return next }) dialog.clear() }, }, { title: userMessageMarkdown() ? "Disable user message markdown" : "Enable user message markdown", value: "session.toggle.user_message_markdown", category: "Session", onSelect: (dialog) => { setUserMessageMarkdown((prev) => { const next = !prev kv.set("user_message_markdown", next) return next }) 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 { // Format session transcript as markdown const sessionData = session() const sessionMessages = messages() let transcript = `# ${sessionData.title}\n\n` transcript += `**Session ID:** ${sessionData.id}\n` transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n` transcript += `---\n\n` for (const msg of sessionMessages) { const parts = sync.data.part[msg.id] ?? [] const role = msg.role === "user" ? "User" : "Assistant" transcript += `## ${role}\n\n` for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` } else if (part.type === "tool") { transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` } } transcript += `---\n\n` } // Copy to clipboard 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 to file", value: "session.export", keybind: "session_export", category: "Session", onSelect: async (dialog) => { try { // Format session transcript as markdown const sessionData = session() const sessionMessages = messages() let transcript = `# ${sessionData.title}\n\n` transcript += `**Session ID:** ${sessionData.id}\n` transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n` transcript += `---\n\n` for (const msg of sessionMessages) { const parts = sync.data.part[msg.id] ?? [] const role = msg.role === "user" ? "User" : "Assistant" transcript += `## ${role}\n\n` for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` } else if (part.type === "tool") { transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` } } transcript += `---\n\n` } // Prompt for optional filename const customFilename = await DialogPrompt.show(dialog, "Export filename", { value: `session-${sessionData.id.slice(0, 8)}.md`, }) // Cancel if user pressed escape if (customFilename === null) return // Save to file in current working directory const exportDir = process.cwd() const filename = customFilename.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) { // User edited the file, save the changes 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()} /> )} { prompt = r promptRef.set(r) }} disabled={permissions().length > 0} onSubmit={() => { toBottom() }} sessionID={route.sessionID} />