import { TextAttributes } from "@opentui/core" import { createEffect, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { duration } from "../../duration.ts" import { estimateTokens } from "../../provider/tokens.ts" import { useTui } from "../context.ts" import type { Item } from "../state.ts" import { clipDiff } from "../diffclip.ts" const FILETYPES: Record = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", md: "markdown", zig: "zig" } export const filetypeOf = (diff: string) => FILETYPES[/^\+\+\+ \S*\.(\w+)/m.exec(diff)?.[1] ?? ""] /** The one-line summary of a call before its result says more, e.g. the command or the path. */ function argSummary(args: unknown): string { if (!args || typeof args !== "object") return "" const a = args as Record if (Array.isArray(a.questions)) return (a.questions as { header?: string }[]).map((q) => q.header).filter(Boolean).join(" · ") || "asking" if (Array.isArray(a.todos)) return `${a.todos.length} item${a.todos.length === 1 ? "" : "s"}` if (typeof a.patch === "string") return [...a.patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1]).join(", ") const v = a.description ?? a.command ?? a.path ?? a.pattern ?? a.id ?? "" return String(v).split("\n")[0]!.slice(0, 120) } function UserMessage(p: { item: Extract }) { const { theme } = useTui() return ( ↳ sent while it worked {p.item.text} ) } function AssistantMessage(p: { item: Extract }) { const { theme, md } = useTui() return ( ) } /** Thinking is one line, never the text as it streams (a growing block jumped about as it * re-laid itself out; what matters is the tools and the answer): "Thinking." "Thinking.." * "Thinking..." while it thinks, then "Thought for 12.3s · 1,234 tokens" — this thought's time * and tokens, nothing else (the task's are on the line at the bottom). The tokens are the * provider's count when the step had this one thought, else an estimate (~). Ctrl+R opens a * finished thought. */ function Reasoning(p: { item: Extract }) { const { theme, thought, view } = useTui() const [now, setNow] = createSignal(Date.now()) createEffect(() => { if (!p.item.streaming) return const t = setInterval(() => setNow(Date.now()), 200) onCleanup(() => clearInterval(t)) }) const tokens = () => { const n = p.item.tokens ?? estimateTokens(p.item.text) return `${p.item.tokens === undefined ? "~" : ""}${n.toLocaleString("en")} token${n === 1 ? "" : "s"}` } const dots = () => ".".repeat(1 + (Math.floor((now() - p.item.started) / 400) % 3)) const line = () => (p.item.streaming ? `Thinking${dots()}` : p.item.ms !== undefined ? `Thought for ${duration(p.item.ms)} · ${tokens()}` : `Thought · ${tokens()}`) return ( {line()} ) } function ToolLine(p: { item: Extract }) { const { theme, view } = useTui() const c = () => theme().colors const emoji = () => (p.item.status === "denied" ? theme().icons.denied : (theme().toolEmoji[p.item.name] ?? (p.item.name.startsWith("mcp__") ? theme().icons.mcp : theme().icons.other))) const title = () => p.item.title ?? argSummary(p.item.args) const time = () => (p.item.status === "running" ? "…" : p.item.ms !== undefined ? duration(p.item.ms) : "") const tail = () => (p.item.output ?? "").trimEnd().split("\n").slice(-12).join("\n") // Short in the transcript, whole with ctrl+o; always a diff the renderer can parse. const shown = () => (view.state.expandTools ? { diff: p.item.diff ?? "", hidden: 0 } : clipDiff(p.item.diff ?? "", 24)) return ( {theme().branding.toolPrefix} {emoji()}{" "} {p.item.name.padEnd(6)}{" "} {title()}{" "} {time()} ✗ {(ch) => ( {" "}{theme().branding.toolPrefix} {theme().toolEmoji[ch.name] ?? (ch.name.startsWith("mcp__") ? theme().icons.mcp : theme().icons.other)}{" "} {ch.name.padEnd(6)}{" "} {ch.title} )} 0}> {`… ${shown().hidden} more line${shown().hidden === 1 ? "" : "s"} (ctrl+o)`} {tail()} ) } /** A unified diff in the transcript, given its width outright. Left to fill its box, OpenTUI's * diff wrapped long lines at a width measured before the transcript's scrollbar took its column: * with the scrollbar showing, a long line ran one column past its own background. The box's laid-out width is right, so the diff gets that number. */ function WrappedDiff(p: { patch: string; filetype?: string }) { const { theme } = useTui() const c = () => theme().colors const [width, setWidth] = createSignal() return ( 0 && w !== width()) setTimeout(() => setWidth(w), 0) }} > ) } /** A multi-file diff (/diff), one block per file. */ function DiffBlock(p: { item: Extract }) { const { theme } = useTui() const c = () => theme().colors const files = () => p.item.patch .split(/^(?=diff --git )/m) .filter((f) => f.startsWith("diff --git")) .map((f) => ({ name: /^diff --git a\/(.+?) b\//.exec(f)?.[1] ?? "?", patch: f })) return ( {p.item.title} {(f) => ( {f.name} )} ) } function Notice(p: { item: Extract }) { const { theme } = useTui() return ( {p.item.level === "warn" ? "⚠ " : "· "} {p.item.text} ) } function ErrorLine(p: { item: Extract }) { const { theme } = useTui() return ( ✗ {p.item.text} ) } export function Messages() { const { view } = useTui() return ( {(item) => ( } /> } /> } /> } /> } /> } /> } /> )} ) }