| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206 |
- import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
- import type { InternalTuiPlugin } from "../../plugin/internal"
- import { useSyncV2 } from "@tui/context/sync-v2"
- import { SplitBorder } from "@tui/component/border"
- import { Spinner } from "@tui/component/spinner"
- import { useTheme } from "@tui/context/theme"
- import { useLocal } from "@tui/context/local"
- import { reasoningSummary, useThinkingMode } from "@tui/context/thinking"
- import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
- import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
- import { useBindings } from "../../keymap"
- import { Locale } from "@/util/locale"
- import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
- import { webSearchProviderLabel } from "@/tool/websearch"
- import path from "path"
- import stripAnsi from "strip-ansi"
- import type {
- SessionMessage,
- SessionMessageAgentSwitched,
- SessionMessageAssistant,
- SessionMessageAssistantReasoning,
- SessionMessageAssistantText,
- SessionMessageAssistantTool,
- SessionMessageCompaction,
- SessionMessageModelSwitched,
- SessionMessageShell,
- SessionMessageUser,
- ToolFileContent,
- ToolTextContent,
- } from "@opencode-ai/sdk/v2"
- import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
- import { collapseToolOutput } from "../../util/collapse-tool-output"
- const id = "internal:session-v2-debug"
- const route = "session.v2.messages"
- function currentSessionID(api: TuiPluginApi) {
- const current = api.route.current
- if (current.name !== "session") return
- const sessionID = current.params?.sessionID
- return typeof sessionID === "string" ? sessionID : undefined
- }
- function View(props: { api: TuiPluginApi; sessionID: string }) {
- const sync = useSyncV2()
- const dimensions = useTerminalDimensions()
- const { theme, syntax, subtleSyntax } = useTheme()
- const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
- const renderedMessages = createMemo(() => messages().toReversed())
- const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
- const lastUserCreated = (index: number) =>
- renderedMessages()
- .slice(0, index)
- .findLast((message) => message.type === "user")?.time.created
- createEffect(() => {
- void sync.session.message.sync(props.sessionID)
- })
- useBindings(() => ({
- bindings: [
- {
- key: "escape",
- desc: "Back to session",
- group: "Session",
- cmd() {
- props.api.route.navigate("session", { sessionID: props.sessionID })
- },
- },
- ],
- }))
- return (
- <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
- <box flexDirection="row">
- <box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
- <scrollbox
- viewportOptions={{ paddingRight: 0 }}
- verticalScrollbarOptions={{ visible: false }}
- stickyScroll={true}
- stickyStart="bottom"
- flexGrow={1}
- >
- <box height={1} />
- <Show when={messages().length === 0}>
- <MissingData label="Messages" detail="No v2 messages loaded from useSyncV2 yet." />
- </Show>
- <For each={renderedMessages()}>
- {(message, index) => (
- <Switch>
- <Match when={message.type === "user"}>
- <UserMessage message={message as SessionMessageUser} index={index()} />
- </Match>
- <Match when={message.type === "assistant"}>
- <AssistantMessage
- message={message as SessionMessageAssistant}
- sessionID={props.sessionID}
- last={lastAssistant()?.id === message.id}
- syntax={syntax()}
- subtleSyntax={subtleSyntax()}
- start={lastUserCreated(index())}
- />
- </Match>
- <Match when={message.type === "synthetic"}>
- <></>
- </Match>
- <Match when={message.type === "shell"}>
- <ShellMessage message={message as SessionMessageShell} />
- </Match>
- <Match when={message.type === "compaction"}>
- <CompactionMessage message={message as SessionMessageCompaction} />
- </Match>
- <Match when={message.type === "agent-switched"}>
- <AgentSwitchedMessage message={message as SessionMessageAgentSwitched} />
- </Match>
- <Match when={message.type === "model-switched"}>
- <ModelSwitchedMessage message={message as SessionMessageModelSwitched} />
- </Match>
- <Match when={true}>
- <UnknownMessage message={message} />
- </Match>
- </Switch>
- )}
- </For>
- </scrollbox>
- <MissingData
- label="Session prompt, permission prompt, question prompt, sidebar"
- detail="The v2 message endpoint only exposes messages, so these session UI regions cannot be rendered here. Press Esc to return to the live session."
- />
- </box>
- </box>
- </box>
- )
- }
- function MissingData(props: { label: string; detail: string }) {
- const { theme } = useTheme()
- return (
- <box
- border={["left"]}
- customBorderChars={SplitBorder.customBorderChars}
- borderColor={theme.warning}
- backgroundColor={theme.backgroundPanel}
- paddingLeft={2}
- paddingTop={1}
- paddingBottom={1}
- marginTop={1}
- flexShrink={0}
- >
- <text fg={theme.text}>
- <span style={{ bg: theme.warning, fg: theme.background, bold: true }}> MISSING DATA </span> {props.label}
- </text>
- <text fg={theme.textMuted}>{props.detail}</text>
- </box>
- )
- }
- function UserMessage(props: { message: SessionMessageUser; index: number }) {
- const { theme } = useTheme()
- const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])])
- return (
- <box
- id={props.message.id}
- border={["left"]}
- borderColor={theme.secondary}
- customBorderChars={SplitBorder.customBorderChars}
- marginTop={props.index === 0 ? 0 : 1}
- flexShrink={0}
- paddingTop={1}
- paddingBottom={1}
- paddingLeft={2}
- backgroundColor={theme.backgroundPanel}
- >
- <text fg={theme.text}>{props.message.text}</text>
- <Show when={attachments().length}>
- <box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
- <For each={props.message.files ?? []}>
- {(file) => (
- <text fg={theme.text}>
- <span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
- <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
- </text>
- )}
- </For>
- <For each={props.message.agents ?? []}>
- {(agent) => (
- <text fg={theme.text}>
- <span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
- <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
- </text>
- )}
- </For>
- </box>
- </Show>
- </box>
- )
- }
- function ShellMessage(props: { message: SessionMessageShell }) {
- const { theme } = useTheme()
- const dimensions = useTerminalDimensions()
- const output = createMemo(() => stripAnsi(props.message.output.trim()))
- const [expanded, setExpanded] = createSignal(false)
- const maxLines = 10
- const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
- const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
- const limited = createMemo(() => {
- if (expanded() || !collapsed().overflow) return output()
- return collapsed().output
- })
- return (
- <BlockTool
- title="# Shell"
- spinner={!props.message.time.completed}
- onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
- >
- <box gap={1}>
- <text fg={theme.text}>$ {props.message.command}</text>
- <Show when={output()}>
- <text fg={theme.text}>{limited()}</text>
- </Show>
- <Show when={collapsed().overflow}>
- <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
- </Show>
- </box>
- </BlockTool>
- )
- }
- function CompactionMessage(props: { message: SessionMessageCompaction }) {
- const { theme, syntax } = useTheme()
- return (
- <box
- marginTop={1}
- border={["top"]}
- title={props.message.reason === "auto" ? " Auto Compaction " : " Compaction "}
- titleAlignment="center"
- borderColor={theme.borderActive}
- flexShrink={0}
- >
- <Show when={props.message.summary}>
- {(summary) => (
- <box paddingLeft={3} paddingTop={1}>
- <code
- filetype="markdown"
- drawUnstyledText={false}
- streaming={false}
- syntaxStyle={syntax()}
- content={summary().trim()}
- conceal={true}
- fg={theme.text}
- />
- </box>
- )}
- </Show>
- </box>
- )
- }
- function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) {
- const { theme } = useTheme()
- const local = useLocal()
- return (
- <box paddingLeft={3} marginTop={1} flexShrink={0}>
- <text>
- <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
- <span style={{ fg: theme.textMuted }}>Switched agent to </span>
- <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
- </text>
- </box>
- )
- }
- function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) {
- const { theme } = useTheme()
- const model = createMemo(() => {
- const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
- return `${props.message.model.providerID}/${props.message.model.id}${variant}`
- })
- return (
- <box paddingLeft={3} marginTop={1} flexShrink={0}>
- <text>
- <span style={{ fg: theme.secondary }}>◇ </span>
- <span style={{ fg: theme.textMuted }}>Switched model to </span>
- <span style={{ fg: theme.text }}>{model()}</span>
- </text>
- </box>
- )
- }
- function UnknownMessage(props: { message: SessionMessage }) {
- return <MissingData label="Unknown message type" detail={JSON.stringify(props.message)} />
- }
- function AssistantMessage(props: {
- message: SessionMessageAssistant
- sessionID: string
- last: boolean
- syntax: SyntaxStyle
- subtleSyntax: SyntaxStyle
- start?: number
- }) {
- const { theme } = useTheme()
- const local = useLocal()
- const duration = createMemo(() => {
- if (!props.message.time.completed) return 0
- return props.message.time.completed - (props.start ?? props.message.time.created)
- })
- const model = createMemo(() => {
- const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
- return `${props.message.model.providerID}/${props.message.model.id}${variant}`
- })
- const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish))
- return (
- <>
- <For each={props.message.content}>
- {(part) => (
- <Switch>
- <Match when={part.type === "text"}>
- <AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
- </Match>
- <Match when={part.type === "reasoning"}>
- <AssistantReasoning
- part={part as SessionMessageAssistantReasoning}
- subtleSyntax={props.subtleSyntax}
- completedAt={() => props.message.time.completed}
- />
- </Match>
- <Match when={part.type === "tool"}>
- <AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
- </Match>
- </Switch>
- )}
- </For>
- <Show when={props.message.content.length === 0}>
- <MissingData label="Assistant content" detail={`Assistant message ${props.message.id} has no content items.`} />
- </Show>
- <Show when={props.message.error}>
- <box
- border={["left"]}
- paddingTop={1}
- paddingBottom={1}
- paddingLeft={2}
- marginTop={1}
- backgroundColor={theme.backgroundPanel}
- customBorderChars={SplitBorder.customBorderChars}
- borderColor={theme.error}
- flexShrink={0}
- >
- <text fg={theme.textMuted}>{props.message.error}</text>
- </box>
- </Show>
- <Show when={props.last || final() || props.message.error}>
- <box paddingLeft={3} flexShrink={0}>
- <text marginTop={1}>
- <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
- <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
- <span style={{ fg: theme.textMuted }}> · {model()}</span>
- <Show when={duration()}>
- <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
- </Show>
- </text>
- </box>
- </Show>
- </>
- )
- }
- function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
- const { theme } = useTheme()
- return (
- <Show when={props.part.text.trim()}>
- <box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
- <code
- filetype="markdown"
- drawUnstyledText={false}
- streaming={true}
- syntaxStyle={props.syntax}
- content={props.part.text.trim()}
- conceal={true}
- fg={theme.text}
- />
- </box>
- </Show>
- )
- }
- function AssistantReasoning(props: {
- part: SessionMessageAssistantReasoning
- subtleSyntax: SyntaxStyle
- completedAt: () => number | undefined
- }) {
- const { theme } = useTheme()
- const thinking = useThinkingMode()
- const [expanded, setExpanded] = createSignal(false)
- const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
- const inMinimal = createMemo(() => thinking.mode() === "hide")
- // v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
- // in the v2 SDK); we settle on parent-message completion instead.
- const isDone = createMemo(() => props.completedAt() !== undefined)
- const summary = createMemo(() => reasoningSummary(content()))
- const toggle = () => {
- if (!inMinimal()) return
- setExpanded((prev) => !prev)
- }
- return (
- <Show when={content()}>
- <box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
- <box onMouseUp={toggle}>
- <ReasoningHeader
- toggleable={inMinimal()}
- open={!inMinimal() || expanded()}
- done={isDone()}
- title={summary().title}
- />
- </box>
- <Show when={(!inMinimal() || expanded()) && summary().body}>
- <box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
- <code
- filetype="markdown"
- drawUnstyledText={false}
- streaming={true}
- syntaxStyle={props.subtleSyntax}
- content={summary().body}
- conceal={true}
- fg={theme.textMuted}
- />
- </box>
- </Show>
- </box>
- </Show>
- )
- }
- function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
- const { theme } = useTheme()
- const fg = () =>
- props.open
- ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
- : theme.warning
- return (
- <Switch>
- <Match when={!props.done}>
- <box flexDirection="row">
- <Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
- </box>
- </Match>
- <Match when={true}>
- <text fg={fg()} wrapMode="none">
- <Show when={props.toggleable}>
- <span>{props.open ? "- " : "+ "}</span>
- </Show>
- <span>Thought</span>
- <Show when={props.title}>
- <span>: </span>
- <span>{props.title}</span>
- </Show>
- </text>
- </Match>
- </Switch>
- )
- }
- function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) {
- const input = createMemo(() => toolInputRecord(props.part.state.input))
- const toolprops = {
- get input() {
- return input()
- },
- get metadata() {
- return props.part.provider?.metadata ?? {}
- },
- get output() {
- return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
- },
- sessionID: props.sessionID,
- part: props.part,
- }
- return (
- <Switch>
- <Match when={props.part.name === "bash"}>
- <Bash {...toolprops} />
- </Match>
- <Match when={props.part.name === "glob"}>
- <Glob {...toolprops} />
- </Match>
- <Match when={props.part.name === "read"}>
- <Read {...toolprops} />
- </Match>
- <Match when={props.part.name === "grep"}>
- <Grep {...toolprops} />
- </Match>
- <Match when={props.part.name === "webfetch"}>
- <WebFetch {...toolprops} />
- </Match>
- <Match when={props.part.name === "websearch"}>
- <WebSearch {...toolprops} />
- </Match>
- <Match when={props.part.name === "write"}>
- <Write {...toolprops} />
- </Match>
- <Match when={props.part.name === "edit"}>
- <Edit {...toolprops} />
- </Match>
- <Match when={props.part.name === "apply_patch"}>
- <ApplyPatch {...toolprops} />
- </Match>
- <Match when={props.part.name === "todowrite"}>
- <TodoWrite {...toolprops} />
- </Match>
- <Match when={props.part.name === "question"}>
- <Question {...toolprops} />
- </Match>
- <Match when={props.part.name === "skill"}>
- <Skill {...toolprops} />
- </Match>
- <Match when={props.part.name === "task"}>
- <Task {...toolprops} />
- </Match>
- <Match when={true}>
- <GenericTool {...toolprops} />
- </Match>
- </Switch>
- )
- }
- type ToolProps = {
- input: Record<string, unknown>
- metadata: Record<string, unknown>
- output?: string
- sessionID: string
- part: SessionMessageAssistantTool
- }
- function GenericTool(props: ToolProps) {
- const { theme } = useTheme()
- const dimensions = useTerminalDimensions()
- const output = createMemo(() => props.output?.trim() ?? "")
- const [expanded, setExpanded] = createSignal(false)
- const maxLines = 3
- const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
- const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
- const limited = createMemo(() => {
- if (expanded() || !collapsed().overflow) return output()
- return collapsed().output
- })
- return (
- <Show
- when={output()}
- fallback={
- <InlineTool icon="⚙" pending="Writing command..." complete={toolComplete(props.part)} part={props.part}>
- {props.part.name} {input(props.input)}
- </InlineTool>
- }
- >
- <BlockTool
- title={`# ${props.part.name} ${input(props.input)}`}
- part={props.part}
- onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
- >
- <box gap={1}>
- <text fg={theme.text}>{limited()}</text>
- <Show when={collapsed().overflow}>
- <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
- </Show>
- </box>
- </BlockTool>
- </Show>
- )
- }
- function InlineTool(props: {
- icon: string
- complete: unknown
- pending: string
- spinner?: boolean
- children: JSX.Element
- part: SessionMessageAssistantTool
- }) {
- const { theme } = useTheme()
- const renderer = useRenderer()
- const [margin, setMargin] = createSignal(0)
- const [hover, setHover] = createSignal(false)
- const [showError, setShowError] = createSignal(false)
- const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
- const complete = createMemo(() => !!props.complete)
- const denied = createMemo(() => {
- const message = error()
- if (!message) return false
- return (
- message.includes("QuestionRejectedError") ||
- message.includes("rejected permission") ||
- message.includes("specified a rule") ||
- message.includes("user dismissed")
- )
- })
- const fg = createMemo(() => {
- if (error()) return theme.error
- if (complete()) return theme.textMuted
- return theme.text
- })
- const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
- return (
- <box
- marginTop={margin()}
- paddingLeft={3}
- flexShrink={0}
- flexDirection="row"
- gap={1}
- backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
- onMouseOver={() => error() && setHover(true)}
- onMouseOut={() => setHover(false)}
- onMouseUp={() => {
- if (!error()) return
- if (renderer.getSelection()?.getSelectedText()) return
- setShowError((prev) => !prev)
- }}
- renderBefore={function () {
- const el = this as BoxRenderable
- const parent = el.parent
- if (!parent) return
- const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
- if (!previous) {
- setMargin(0)
- return
- }
- if (previous.id.startsWith("text")) setMargin(1)
- }}
- >
- <box flexShrink={0}>
- <Switch>
- <Match when={props.spinner}>
- <Spinner color={theme.text} />
- </Match>
- <Match when={complete()}>
- <text fg={fg()} attributes={attributes()}>
- {props.icon}
- </text>
- </Match>
- <Match when={true}>
- <text fg={fg()} attributes={attributes()}>
- ~
- </text>
- </Match>
- </Switch>
- </box>
- <box flexGrow={1}>
- <box>
- <Switch>
- <Match when={complete()}>
- <text fg={fg()} attributes={attributes()}>
- {props.children}
- </text>
- </Match>
- <Match when={true}>
- <text fg={fg()} attributes={attributes()}>
- {props.pending}
- </text>
- </Match>
- </Switch>
- </box>
- <Show when={showError() && error()}>
- <box>
- <text fg={theme.error}>{error()}</text>
- </box>
- </Show>
- </box>
- </box>
- )
- }
- function BlockTool(props: {
- title: string
- children: JSX.Element
- part?: SessionMessageAssistantTool
- onClick?: () => void
- spinner?: boolean
- }) {
- const { theme } = useTheme()
- const renderer = useRenderer()
- const [hover, setHover] = createSignal(false)
- const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
- return (
- <box
- border={["left"]}
- paddingTop={1}
- paddingBottom={1}
- paddingLeft={2}
- marginTop={1}
- gap={1}
- backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
- customBorderChars={SplitBorder.customBorderChars}
- borderColor={theme.background}
- onMouseOver={() => props.onClick && setHover(true)}
- onMouseOut={() => setHover(false)}
- onMouseUp={() => {
- if (renderer.getSelection()?.getSelectedText()) return
- props.onClick?.()
- }}
- flexShrink={0}
- >
- <Show
- when={props.spinner}
- fallback={
- <text paddingLeft={3} fg={theme.textMuted}>
- {props.title}
- </text>
- }
- >
- <Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
- </Show>
- {props.children}
- <Show when={error()}>
- <text fg={theme.error}>{error()}</text>
- </Show>
- </box>
- )
- }
- function Bash(props: ToolProps) {
- const { theme } = useTheme()
- const dimensions = useTerminalDimensions()
- const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
- const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
- const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
- const [expanded, setExpanded] = createSignal(false)
- const maxLines = 10
- const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
- const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
- const limited = createMemo(() => {
- if (expanded() || !collapsed().overflow) return output()
- return collapsed().output
- })
- return (
- <Switch>
- <Match when={output()}>
- <BlockTool
- title={title()}
- part={props.part}
- spinner={props.part.state.status === "running"}
- onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
- >
- <box gap={1}>
- <text fg={theme.text}>$ {command()}</text>
- <text fg={theme.text}>{limited()}</text>
- <Show when={collapsed().overflow}>
- <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
- </Show>
- </box>
- </BlockTool>
- </Match>
- <Match when={true}>
- <InlineTool icon="$" pending="Writing command..." complete={command()} part={props.part}>
- {command()}
- </InlineTool>
- </Match>
- </Switch>
- )
- }
- function Glob(props: ToolProps) {
- return (
- <InlineTool icon="✱" pending="Finding files..." complete={toolComplete(props.part)} part={props.part}>
- Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
- <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
- <Show when={numberValue(props.metadata.count)}>
- {(count) => (
- <>
- ({count()} {count() === 1 ? "match" : "matches"})
- </>
- )}
- </Show>
- </InlineTool>
- )
- }
- function Read(props: ToolProps) {
- const { theme } = useTheme()
- const loaded = createMemo(() =>
- arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"),
- )
- return (
- <>
- <InlineTool
- icon="→"
- pending="Reading file..."
- complete={stringValue(props.input.filePath) ?? pendingInput(props.part)}
- spinner={props.part.state.status === "running"}
- part={props.part}
- >
- Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "}
- {input(props.input, ["filePath"])}
- </InlineTool>
- <For each={loaded()}>
- {(filepath) => (
- <box paddingLeft={3} flexShrink={0}>
- <text paddingLeft={3} fg={theme.textMuted}>
- ↳ Loaded {normalizePath(filepath)}
- </text>
- </box>
- )}
- </For>
- </>
- )
- }
- function Grep(props: ToolProps) {
- return (
- <InlineTool icon="✱" pending="Searching content..." complete={toolComplete(props.part)} part={props.part}>
- Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
- <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
- <Show when={numberValue(props.metadata.matches)}>
- {(matches) => (
- <>
- ({matches()} {matches() === 1 ? "match" : "matches"})
- </>
- )}
- </Show>
- </InlineTool>
- )
- }
- function WebFetch(props: ToolProps) {
- return (
- <InlineTool icon="%" pending="Fetching from the web..." complete={toolComplete(props.part)} part={props.part}>
- WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)}
- </InlineTool>
- )
- }
- function WebSearch(props: ToolProps) {
- const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
- return (
- <InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
- {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
- <Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
- </InlineTool>
- )
- }
- function Write(props: ToolProps) {
- const { theme, syntax } = useTheme()
- const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
- const content = createMemo(() => stringValue(props.input.content) ?? "")
- return (
- <Switch>
- <Match when={content() && props.part.state.status === "completed"}>
- <BlockTool title={"# Wrote " + normalizePath(filePath())} part={props.part}>
- <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
- <code
- conceal={false}
- fg={theme.text}
- filetype={filetype(filePath())}
- syntaxStyle={syntax()}
- content={content()}
- />
- </line_number>
- <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
- </BlockTool>
- </Match>
- <Match when={true}>
- <InlineTool icon="←" pending="Preparing write..." complete={filePath()} part={props.part}>
- Write {normalizePath(filePath())}
- </InlineTool>
- </Match>
- </Switch>
- )
- }
- function Edit(props: ToolProps) {
- const { theme, syntax } = useTheme()
- const dimensions = useTerminalDimensions()
- const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
- const diff = createMemo(() => stringValue(props.metadata.diff))
- return (
- <Switch>
- <Match when={diff()}>
- {(diff) => (
- <BlockTool title={"← Edit " + normalizePath(filePath())} part={props.part}>
- <box paddingLeft={1}>
- <diff
- diff={diff()}
- view={dimensions().width > 120 ? "split" : "unified"}
- filetype={filetype(filePath())}
- syntaxStyle={syntax()}
- showLineNumbers={true}
- width="100%"
- wrapMode="word"
- fg={theme.text}
- addedBg={theme.diffAddedBg}
- removedBg={theme.diffRemovedBg}
- contextBg={theme.diffContextBg}
- addedSignColor={theme.diffHighlightAdded}
- removedSignColor={theme.diffHighlightRemoved}
- lineNumberFg={theme.diffLineNumber}
- lineNumberBg={theme.diffContextBg}
- addedLineNumberBg={theme.diffAddedLineNumberBg}
- removedLineNumberBg={theme.diffRemovedLineNumberBg}
- />
- </box>
- <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
- </BlockTool>
- )}
- </Match>
- <Match when={true}>
- <InlineTool icon="←" pending="Preparing edit..." complete={filePath()} part={props.part}>
- Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })}
- </InlineTool>
- </Match>
- </Switch>
- )
- }
- function ApplyPatch(props: ToolProps) {
- const { theme, syntax } = useTheme()
- const dimensions = useTerminalDimensions()
- const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : [])))
- const fileTitle = (file: Record<string, unknown>) => {
- const type = stringValue(file.type)
- const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch"
- if (type === "delete") return "# Deleted " + relativePath
- if (type === "add") return "# Created " + relativePath
- if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath
- return "← Patched " + relativePath
- }
- return (
- <Switch>
- <Match when={files().length > 0}>
- <For each={files()}>
- {(file) => (
- <BlockTool title={fileTitle(file)} part={props.part}>
- <Show
- when={stringValue(file.patch)}
- fallback={
- <text fg={theme.diffRemoved}>
- -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"}
- </text>
- }
- >
- {(patch) => (
- <box paddingLeft={1}>
- <diff
- diff={patch()}
- view={dimensions().width > 120 ? "split" : "unified"}
- filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))}
- syntaxStyle={syntax()}
- showLineNumbers={true}
- width="100%"
- wrapMode="word"
- fg={theme.text}
- addedBg={theme.diffAddedBg}
- removedBg={theme.diffRemovedBg}
- contextBg={theme.diffContextBg}
- addedSignColor={theme.diffHighlightAdded}
- removedSignColor={theme.diffHighlightRemoved}
- lineNumberFg={theme.diffLineNumber}
- lineNumberBg={theme.diffContextBg}
- addedLineNumberBg={theme.diffAddedLineNumberBg}
- removedLineNumberBg={theme.diffRemovedLineNumberBg}
- />
- </box>
- )}
- </Show>
- </BlockTool>
- )}
- </For>
- </Match>
- <Match when={true}>
- <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
- Patch
- </InlineTool>
- </Match>
- </Switch>
- )
- }
- function TodoWrite(props: ToolProps) {
- const { theme } = useTheme()
- const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : [])))
- return (
- <Switch>
- <Match when={todos().length > 0 && props.part.state.status === "completed"}>
- <BlockTool title="# Todos" part={props.part}>
- <box>
- <For each={todos()}>
- {(todo) => (
- <text fg={theme.text}>
- {todoIcon(stringValue(todo.status))} {stringValue(todo.content)}
- </text>
- )}
- </For>
- </box>
- </BlockTool>
- </Match>
- <Match when={true}>
- <InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
- Updating todos...
- </InlineTool>
- </Match>
- </Switch>
- )
- }
- function Question(props: ToolProps) {
- const { theme } = useTheme()
- const questions = createMemo(() =>
- arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])),
- )
- const answers = createMemo(() => arrayValue(props.metadata.answers))
- return (
- <Switch>
- <Match when={answers().length > 0}>
- <BlockTool title="# Questions" part={props.part}>
- <box gap={1}>
- <For each={questions()}>
- {(question, index) => (
- <box>
- <text fg={theme.textMuted}>{stringValue(question.question)}</text>
- <text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
- </box>
- )}
- </For>
- </box>
- </BlockTool>
- </Match>
- <Match when={true}>
- <InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
- Asked {questions().length} question{questions().length === 1 ? "" : "s"}
- </InlineTool>
- </Match>
- </Switch>
- )
- }
- function Skill(props: ToolProps) {
- return (
- <InlineTool icon="→" pending="Loading skill..." complete={toolComplete(props.part)} part={props.part}>
- Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}"
- </InlineTool>
- )
- }
- function Task(props: ToolProps) {
- const content = createMemo(() => {
- const description = stringValue(props.input.description)
- if (!description) return pendingInput(props.part)
- return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}`
- })
- return (
- <InlineTool
- icon="│"
- spinner={props.part.state.status === "running"}
- complete={toolComplete(props.part)}
- pending="Delegating..."
- part={props.part}
- >
- {content()}
- </InlineTool>
- )
- }
- function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
- const { theme } = useTheme()
- const errors = createMemo(() => {
- if (!isRecord(props.diagnostics)) return []
- const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath]
- return arrayValue(value)
- .flatMap((item) => (isRecord(item) ? [item] : []))
- .filter((diagnostic) => diagnostic.severity === 1)
- .slice(0, 3)
- })
- return (
- <Show when={errors().length}>
- <box>
- <For each={errors()}>
- {(diagnostic) => <text fg={theme.error}>Error {stringValue(diagnostic.message)}</text>}
- </For>
- </box>
- </Show>
- )
- }
- function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
- return (content ?? [])
- .map((item) => {
- if (item.type === "text") return item.text.trim()
- const source =
- item.source.type === "data" ? "inline data" : item.source.type === "url" ? item.source.url : item.source.uri
- return `[file ${item.name ?? source}]`
- })
- .filter(Boolean)
- .join("\n")
- }
- function toolInputRecord(input: string | Record<string, unknown>) {
- if (typeof input === "string") return {}
- return input
- }
- function pendingInput(part: SessionMessageAssistantTool) {
- if (part.state.status !== "pending") return ""
- return part.state.input.trim()
- }
- function toolComplete(part: SessionMessageAssistantTool) {
- if (part.state.status === "pending") return pendingInput(part)
- return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running"
- }
- function stringValue(value: unknown) {
- return typeof value === "string" ? value : undefined
- }
- function numberValue(value: unknown) {
- return typeof value === "number" ? value : undefined
- }
- function arrayValue(value: unknown): unknown[] {
- return Array.isArray(value) ? value : []
- }
- function isRecord(value: unknown): value is Record<string, unknown> {
- return !!value && typeof value === "object" && !Array.isArray(value)
- }
- function input(input: Record<string, unknown>, omit?: string[]) {
- const primitives = Object.entries(input).filter(([key, value]) => {
- if (omit?.includes(key)) return false
- return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
- })
- if (primitives.length === 0) return ""
- return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
- }
- function normalizePath(input?: string) {
- if (!input) return ""
- const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input)
- const relative = path.relative(process.cwd(), absolute)
- if (!relative) return "."
- if (!relative.startsWith("..")) return relative
- return absolute
- }
- function filetype(input?: string) {
- if (!input) return "none"
- const language = LANGUAGE_EXTENSIONS[path.extname(input)]
- if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
- return language
- }
- function todoIcon(status?: string) {
- if (status === "completed") return "✓"
- if (status === "in_progress") return "~"
- if (status === "cancelled") return "✕"
- return "☐"
- }
- function formatAnswer(answer: unknown) {
- if (!Array.isArray(answer)) return "(no answer)"
- if (answer.length === 0) return "(no answer)"
- return answer.filter((item): item is string => typeof item === "string").join(", ")
- }
- const tui: TuiPlugin = async (api) => {
- api.route.register([
- {
- name: route,
- render(input) {
- const sessionID = input.params?.sessionID
- if (typeof sessionID !== "string") {
- return <text fg={api.theme.current.error}>Missing sessionID</text>
- }
- return <View api={api} sessionID={sessionID} />
- },
- },
- ])
- api.keymap.registerLayer({
- commands: [
- {
- name: route,
- title: "View v2 session messages",
- category: "Debug",
- namespace: "palette",
- suggested: () => api.route.current.name === "session",
- enabled: () => api.route.current.name === "session",
- run() {
- const sessionID = currentSessionID(api)
- if (!sessionID) return
- api.route.navigate(route, { sessionID })
- api.ui.dialog.clear()
- },
- },
- ],
- })
- }
- const plugin: InternalTuiPlugin = {
- id,
- tui,
- }
- export default plugin
|