session-v2.tsx 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206
  1. import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
  2. import type { InternalTuiPlugin } from "../../plugin/internal"
  3. import { useSyncV2 } from "@tui/context/sync-v2"
  4. import { SplitBorder } from "@tui/component/border"
  5. import { Spinner } from "@tui/component/spinner"
  6. import { useTheme } from "@tui/context/theme"
  7. import { useLocal } from "@tui/context/local"
  8. import { reasoningSummary, useThinkingMode } from "@tui/context/thinking"
  9. import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
  10. import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
  11. import { useBindings } from "../../keymap"
  12. import { Locale } from "@/util/locale"
  13. import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
  14. import { webSearchProviderLabel } from "@/tool/websearch"
  15. import path from "path"
  16. import stripAnsi from "strip-ansi"
  17. import type {
  18. SessionMessage,
  19. SessionMessageAgentSwitched,
  20. SessionMessageAssistant,
  21. SessionMessageAssistantReasoning,
  22. SessionMessageAssistantText,
  23. SessionMessageAssistantTool,
  24. SessionMessageCompaction,
  25. SessionMessageModelSwitched,
  26. SessionMessageShell,
  27. SessionMessageUser,
  28. ToolFileContent,
  29. ToolTextContent,
  30. } from "@opencode-ai/sdk/v2"
  31. import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
  32. import { collapseToolOutput } from "../../util/collapse-tool-output"
  33. const id = "internal:session-v2-debug"
  34. const route = "session.v2.messages"
  35. function currentSessionID(api: TuiPluginApi) {
  36. const current = api.route.current
  37. if (current.name !== "session") return
  38. const sessionID = current.params?.sessionID
  39. return typeof sessionID === "string" ? sessionID : undefined
  40. }
  41. function View(props: { api: TuiPluginApi; sessionID: string }) {
  42. const sync = useSyncV2()
  43. const dimensions = useTerminalDimensions()
  44. const { theme, syntax, subtleSyntax } = useTheme()
  45. const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
  46. const renderedMessages = createMemo(() => messages().toReversed())
  47. const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
  48. const lastUserCreated = (index: number) =>
  49. renderedMessages()
  50. .slice(0, index)
  51. .findLast((message) => message.type === "user")?.time.created
  52. createEffect(() => {
  53. void sync.session.message.sync(props.sessionID)
  54. })
  55. useBindings(() => ({
  56. bindings: [
  57. {
  58. key: "escape",
  59. desc: "Back to session",
  60. group: "Session",
  61. cmd() {
  62. props.api.route.navigate("session", { sessionID: props.sessionID })
  63. },
  64. },
  65. ],
  66. }))
  67. return (
  68. <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
  69. <box flexDirection="row">
  70. <box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
  71. <scrollbox
  72. viewportOptions={{ paddingRight: 0 }}
  73. verticalScrollbarOptions={{ visible: false }}
  74. stickyScroll={true}
  75. stickyStart="bottom"
  76. flexGrow={1}
  77. >
  78. <box height={1} />
  79. <Show when={messages().length === 0}>
  80. <MissingData label="Messages" detail="No v2 messages loaded from useSyncV2 yet." />
  81. </Show>
  82. <For each={renderedMessages()}>
  83. {(message, index) => (
  84. <Switch>
  85. <Match when={message.type === "user"}>
  86. <UserMessage message={message as SessionMessageUser} index={index()} />
  87. </Match>
  88. <Match when={message.type === "assistant"}>
  89. <AssistantMessage
  90. message={message as SessionMessageAssistant}
  91. sessionID={props.sessionID}
  92. last={lastAssistant()?.id === message.id}
  93. syntax={syntax()}
  94. subtleSyntax={subtleSyntax()}
  95. start={lastUserCreated(index())}
  96. />
  97. </Match>
  98. <Match when={message.type === "synthetic"}>
  99. <></>
  100. </Match>
  101. <Match when={message.type === "shell"}>
  102. <ShellMessage message={message as SessionMessageShell} />
  103. </Match>
  104. <Match when={message.type === "compaction"}>
  105. <CompactionMessage message={message as SessionMessageCompaction} />
  106. </Match>
  107. <Match when={message.type === "agent-switched"}>
  108. <AgentSwitchedMessage message={message as SessionMessageAgentSwitched} />
  109. </Match>
  110. <Match when={message.type === "model-switched"}>
  111. <ModelSwitchedMessage message={message as SessionMessageModelSwitched} />
  112. </Match>
  113. <Match when={true}>
  114. <UnknownMessage message={message} />
  115. </Match>
  116. </Switch>
  117. )}
  118. </For>
  119. </scrollbox>
  120. <MissingData
  121. label="Session prompt, permission prompt, question prompt, sidebar"
  122. 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."
  123. />
  124. </box>
  125. </box>
  126. </box>
  127. )
  128. }
  129. function MissingData(props: { label: string; detail: string }) {
  130. const { theme } = useTheme()
  131. return (
  132. <box
  133. border={["left"]}
  134. customBorderChars={SplitBorder.customBorderChars}
  135. borderColor={theme.warning}
  136. backgroundColor={theme.backgroundPanel}
  137. paddingLeft={2}
  138. paddingTop={1}
  139. paddingBottom={1}
  140. marginTop={1}
  141. flexShrink={0}
  142. >
  143. <text fg={theme.text}>
  144. <span style={{ bg: theme.warning, fg: theme.background, bold: true }}> MISSING DATA </span> {props.label}
  145. </text>
  146. <text fg={theme.textMuted}>{props.detail}</text>
  147. </box>
  148. )
  149. }
  150. function UserMessage(props: { message: SessionMessageUser; index: number }) {
  151. const { theme } = useTheme()
  152. const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])])
  153. return (
  154. <box
  155. id={props.message.id}
  156. border={["left"]}
  157. borderColor={theme.secondary}
  158. customBorderChars={SplitBorder.customBorderChars}
  159. marginTop={props.index === 0 ? 0 : 1}
  160. flexShrink={0}
  161. paddingTop={1}
  162. paddingBottom={1}
  163. paddingLeft={2}
  164. backgroundColor={theme.backgroundPanel}
  165. >
  166. <text fg={theme.text}>{props.message.text}</text>
  167. <Show when={attachments().length}>
  168. <box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
  169. <For each={props.message.files ?? []}>
  170. {(file) => (
  171. <text fg={theme.text}>
  172. <span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
  173. <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
  174. </text>
  175. )}
  176. </For>
  177. <For each={props.message.agents ?? []}>
  178. {(agent) => (
  179. <text fg={theme.text}>
  180. <span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
  181. <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
  182. </text>
  183. )}
  184. </For>
  185. </box>
  186. </Show>
  187. </box>
  188. )
  189. }
  190. function ShellMessage(props: { message: SessionMessageShell }) {
  191. const { theme } = useTheme()
  192. const dimensions = useTerminalDimensions()
  193. const output = createMemo(() => stripAnsi(props.message.output.trim()))
  194. const [expanded, setExpanded] = createSignal(false)
  195. const maxLines = 10
  196. const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
  197. const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
  198. const limited = createMemo(() => {
  199. if (expanded() || !collapsed().overflow) return output()
  200. return collapsed().output
  201. })
  202. return (
  203. <BlockTool
  204. title="# Shell"
  205. spinner={!props.message.time.completed}
  206. onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
  207. >
  208. <box gap={1}>
  209. <text fg={theme.text}>$ {props.message.command}</text>
  210. <Show when={output()}>
  211. <text fg={theme.text}>{limited()}</text>
  212. </Show>
  213. <Show when={collapsed().overflow}>
  214. <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
  215. </Show>
  216. </box>
  217. </BlockTool>
  218. )
  219. }
  220. function CompactionMessage(props: { message: SessionMessageCompaction }) {
  221. const { theme, syntax } = useTheme()
  222. return (
  223. <box
  224. marginTop={1}
  225. border={["top"]}
  226. title={props.message.reason === "auto" ? " Auto Compaction " : " Compaction "}
  227. titleAlignment="center"
  228. borderColor={theme.borderActive}
  229. flexShrink={0}
  230. >
  231. <Show when={props.message.summary}>
  232. {(summary) => (
  233. <box paddingLeft={3} paddingTop={1}>
  234. <code
  235. filetype="markdown"
  236. drawUnstyledText={false}
  237. streaming={false}
  238. syntaxStyle={syntax()}
  239. content={summary().trim()}
  240. conceal={true}
  241. fg={theme.text}
  242. />
  243. </box>
  244. )}
  245. </Show>
  246. </box>
  247. )
  248. }
  249. function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) {
  250. const { theme } = useTheme()
  251. const local = useLocal()
  252. return (
  253. <box paddingLeft={3} marginTop={1} flexShrink={0}>
  254. <text>
  255. <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
  256. <span style={{ fg: theme.textMuted }}>Switched agent to </span>
  257. <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
  258. </text>
  259. </box>
  260. )
  261. }
  262. function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) {
  263. const { theme } = useTheme()
  264. const model = createMemo(() => {
  265. const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
  266. return `${props.message.model.providerID}/${props.message.model.id}${variant}`
  267. })
  268. return (
  269. <box paddingLeft={3} marginTop={1} flexShrink={0}>
  270. <text>
  271. <span style={{ fg: theme.secondary }}>◇ </span>
  272. <span style={{ fg: theme.textMuted }}>Switched model to </span>
  273. <span style={{ fg: theme.text }}>{model()}</span>
  274. </text>
  275. </box>
  276. )
  277. }
  278. function UnknownMessage(props: { message: SessionMessage }) {
  279. return <MissingData label="Unknown message type" detail={JSON.stringify(props.message)} />
  280. }
  281. function AssistantMessage(props: {
  282. message: SessionMessageAssistant
  283. sessionID: string
  284. last: boolean
  285. syntax: SyntaxStyle
  286. subtleSyntax: SyntaxStyle
  287. start?: number
  288. }) {
  289. const { theme } = useTheme()
  290. const local = useLocal()
  291. const duration = createMemo(() => {
  292. if (!props.message.time.completed) return 0
  293. return props.message.time.completed - (props.start ?? props.message.time.created)
  294. })
  295. const model = createMemo(() => {
  296. const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
  297. return `${props.message.model.providerID}/${props.message.model.id}${variant}`
  298. })
  299. const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish))
  300. return (
  301. <>
  302. <For each={props.message.content}>
  303. {(part) => (
  304. <Switch>
  305. <Match when={part.type === "text"}>
  306. <AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
  307. </Match>
  308. <Match when={part.type === "reasoning"}>
  309. <AssistantReasoning
  310. part={part as SessionMessageAssistantReasoning}
  311. subtleSyntax={props.subtleSyntax}
  312. completedAt={() => props.message.time.completed}
  313. />
  314. </Match>
  315. <Match when={part.type === "tool"}>
  316. <AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
  317. </Match>
  318. </Switch>
  319. )}
  320. </For>
  321. <Show when={props.message.content.length === 0}>
  322. <MissingData label="Assistant content" detail={`Assistant message ${props.message.id} has no content items.`} />
  323. </Show>
  324. <Show when={props.message.error}>
  325. <box
  326. border={["left"]}
  327. paddingTop={1}
  328. paddingBottom={1}
  329. paddingLeft={2}
  330. marginTop={1}
  331. backgroundColor={theme.backgroundPanel}
  332. customBorderChars={SplitBorder.customBorderChars}
  333. borderColor={theme.error}
  334. flexShrink={0}
  335. >
  336. <text fg={theme.textMuted}>{props.message.error}</text>
  337. </box>
  338. </Show>
  339. <Show when={props.last || final() || props.message.error}>
  340. <box paddingLeft={3} flexShrink={0}>
  341. <text marginTop={1}>
  342. <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
  343. <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
  344. <span style={{ fg: theme.textMuted }}> · {model()}</span>
  345. <Show when={duration()}>
  346. <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
  347. </Show>
  348. </text>
  349. </box>
  350. </Show>
  351. </>
  352. )
  353. }
  354. function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
  355. const { theme } = useTheme()
  356. return (
  357. <Show when={props.part.text.trim()}>
  358. <box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
  359. <code
  360. filetype="markdown"
  361. drawUnstyledText={false}
  362. streaming={true}
  363. syntaxStyle={props.syntax}
  364. content={props.part.text.trim()}
  365. conceal={true}
  366. fg={theme.text}
  367. />
  368. </box>
  369. </Show>
  370. )
  371. }
  372. function AssistantReasoning(props: {
  373. part: SessionMessageAssistantReasoning
  374. subtleSyntax: SyntaxStyle
  375. completedAt: () => number | undefined
  376. }) {
  377. const { theme } = useTheme()
  378. const thinking = useThinkingMode()
  379. const [expanded, setExpanded] = createSignal(false)
  380. const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
  381. const inMinimal = createMemo(() => thinking.mode() === "hide")
  382. // v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
  383. // in the v2 SDK); we settle on parent-message completion instead.
  384. const isDone = createMemo(() => props.completedAt() !== undefined)
  385. const summary = createMemo(() => reasoningSummary(content()))
  386. const toggle = () => {
  387. if (!inMinimal()) return
  388. setExpanded((prev) => !prev)
  389. }
  390. return (
  391. <Show when={content()}>
  392. <box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
  393. <box onMouseUp={toggle}>
  394. <ReasoningHeader
  395. toggleable={inMinimal()}
  396. open={!inMinimal() || expanded()}
  397. done={isDone()}
  398. title={summary().title}
  399. />
  400. </box>
  401. <Show when={(!inMinimal() || expanded()) && summary().body}>
  402. <box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
  403. <code
  404. filetype="markdown"
  405. drawUnstyledText={false}
  406. streaming={true}
  407. syntaxStyle={props.subtleSyntax}
  408. content={summary().body}
  409. conceal={true}
  410. fg={theme.textMuted}
  411. />
  412. </box>
  413. </Show>
  414. </box>
  415. </Show>
  416. )
  417. }
  418. function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
  419. const { theme } = useTheme()
  420. const fg = () =>
  421. props.open
  422. ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
  423. : theme.warning
  424. return (
  425. <Switch>
  426. <Match when={!props.done}>
  427. <box flexDirection="row">
  428. <Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
  429. </box>
  430. </Match>
  431. <Match when={true}>
  432. <text fg={fg()} wrapMode="none">
  433. <Show when={props.toggleable}>
  434. <span>{props.open ? "- " : "+ "}</span>
  435. </Show>
  436. <span>Thought</span>
  437. <Show when={props.title}>
  438. <span>: </span>
  439. <span>{props.title}</span>
  440. </Show>
  441. </text>
  442. </Match>
  443. </Switch>
  444. )
  445. }
  446. function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) {
  447. const input = createMemo(() => toolInputRecord(props.part.state.input))
  448. const toolprops = {
  449. get input() {
  450. return input()
  451. },
  452. get metadata() {
  453. return props.part.provider?.metadata ?? {}
  454. },
  455. get output() {
  456. return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
  457. },
  458. sessionID: props.sessionID,
  459. part: props.part,
  460. }
  461. return (
  462. <Switch>
  463. <Match when={props.part.name === "bash"}>
  464. <Bash {...toolprops} />
  465. </Match>
  466. <Match when={props.part.name === "glob"}>
  467. <Glob {...toolprops} />
  468. </Match>
  469. <Match when={props.part.name === "read"}>
  470. <Read {...toolprops} />
  471. </Match>
  472. <Match when={props.part.name === "grep"}>
  473. <Grep {...toolprops} />
  474. </Match>
  475. <Match when={props.part.name === "webfetch"}>
  476. <WebFetch {...toolprops} />
  477. </Match>
  478. <Match when={props.part.name === "websearch"}>
  479. <WebSearch {...toolprops} />
  480. </Match>
  481. <Match when={props.part.name === "write"}>
  482. <Write {...toolprops} />
  483. </Match>
  484. <Match when={props.part.name === "edit"}>
  485. <Edit {...toolprops} />
  486. </Match>
  487. <Match when={props.part.name === "apply_patch"}>
  488. <ApplyPatch {...toolprops} />
  489. </Match>
  490. <Match when={props.part.name === "todowrite"}>
  491. <TodoWrite {...toolprops} />
  492. </Match>
  493. <Match when={props.part.name === "question"}>
  494. <Question {...toolprops} />
  495. </Match>
  496. <Match when={props.part.name === "skill"}>
  497. <Skill {...toolprops} />
  498. </Match>
  499. <Match when={props.part.name === "task"}>
  500. <Task {...toolprops} />
  501. </Match>
  502. <Match when={true}>
  503. <GenericTool {...toolprops} />
  504. </Match>
  505. </Switch>
  506. )
  507. }
  508. type ToolProps = {
  509. input: Record<string, unknown>
  510. metadata: Record<string, unknown>
  511. output?: string
  512. sessionID: string
  513. part: SessionMessageAssistantTool
  514. }
  515. function GenericTool(props: ToolProps) {
  516. const { theme } = useTheme()
  517. const dimensions = useTerminalDimensions()
  518. const output = createMemo(() => props.output?.trim() ?? "")
  519. const [expanded, setExpanded] = createSignal(false)
  520. const maxLines = 3
  521. const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
  522. const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
  523. const limited = createMemo(() => {
  524. if (expanded() || !collapsed().overflow) return output()
  525. return collapsed().output
  526. })
  527. return (
  528. <Show
  529. when={output()}
  530. fallback={
  531. <InlineTool icon="⚙" pending="Writing command..." complete={toolComplete(props.part)} part={props.part}>
  532. {props.part.name} {input(props.input)}
  533. </InlineTool>
  534. }
  535. >
  536. <BlockTool
  537. title={`# ${props.part.name} ${input(props.input)}`}
  538. part={props.part}
  539. onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
  540. >
  541. <box gap={1}>
  542. <text fg={theme.text}>{limited()}</text>
  543. <Show when={collapsed().overflow}>
  544. <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
  545. </Show>
  546. </box>
  547. </BlockTool>
  548. </Show>
  549. )
  550. }
  551. function InlineTool(props: {
  552. icon: string
  553. complete: unknown
  554. pending: string
  555. spinner?: boolean
  556. children: JSX.Element
  557. part: SessionMessageAssistantTool
  558. }) {
  559. const { theme } = useTheme()
  560. const renderer = useRenderer()
  561. const [margin, setMargin] = createSignal(0)
  562. const [hover, setHover] = createSignal(false)
  563. const [showError, setShowError] = createSignal(false)
  564. const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
  565. const complete = createMemo(() => !!props.complete)
  566. const denied = createMemo(() => {
  567. const message = error()
  568. if (!message) return false
  569. return (
  570. message.includes("QuestionRejectedError") ||
  571. message.includes("rejected permission") ||
  572. message.includes("specified a rule") ||
  573. message.includes("user dismissed")
  574. )
  575. })
  576. const fg = createMemo(() => {
  577. if (error()) return theme.error
  578. if (complete()) return theme.textMuted
  579. return theme.text
  580. })
  581. const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
  582. return (
  583. <box
  584. marginTop={margin()}
  585. paddingLeft={3}
  586. flexShrink={0}
  587. flexDirection="row"
  588. gap={1}
  589. backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
  590. onMouseOver={() => error() && setHover(true)}
  591. onMouseOut={() => setHover(false)}
  592. onMouseUp={() => {
  593. if (!error()) return
  594. if (renderer.getSelection()?.getSelectedText()) return
  595. setShowError((prev) => !prev)
  596. }}
  597. renderBefore={function () {
  598. const el = this as BoxRenderable
  599. const parent = el.parent
  600. if (!parent) return
  601. const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
  602. if (!previous) {
  603. setMargin(0)
  604. return
  605. }
  606. if (previous.id.startsWith("text")) setMargin(1)
  607. }}
  608. >
  609. <box flexShrink={0}>
  610. <Switch>
  611. <Match when={props.spinner}>
  612. <Spinner color={theme.text} />
  613. </Match>
  614. <Match when={complete()}>
  615. <text fg={fg()} attributes={attributes()}>
  616. {props.icon}
  617. </text>
  618. </Match>
  619. <Match when={true}>
  620. <text fg={fg()} attributes={attributes()}>
  621. ~
  622. </text>
  623. </Match>
  624. </Switch>
  625. </box>
  626. <box flexGrow={1}>
  627. <box>
  628. <Switch>
  629. <Match when={complete()}>
  630. <text fg={fg()} attributes={attributes()}>
  631. {props.children}
  632. </text>
  633. </Match>
  634. <Match when={true}>
  635. <text fg={fg()} attributes={attributes()}>
  636. {props.pending}
  637. </text>
  638. </Match>
  639. </Switch>
  640. </box>
  641. <Show when={showError() && error()}>
  642. <box>
  643. <text fg={theme.error}>{error()}</text>
  644. </box>
  645. </Show>
  646. </box>
  647. </box>
  648. )
  649. }
  650. function BlockTool(props: {
  651. title: string
  652. children: JSX.Element
  653. part?: SessionMessageAssistantTool
  654. onClick?: () => void
  655. spinner?: boolean
  656. }) {
  657. const { theme } = useTheme()
  658. const renderer = useRenderer()
  659. const [hover, setHover] = createSignal(false)
  660. const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
  661. return (
  662. <box
  663. border={["left"]}
  664. paddingTop={1}
  665. paddingBottom={1}
  666. paddingLeft={2}
  667. marginTop={1}
  668. gap={1}
  669. backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
  670. customBorderChars={SplitBorder.customBorderChars}
  671. borderColor={theme.background}
  672. onMouseOver={() => props.onClick && setHover(true)}
  673. onMouseOut={() => setHover(false)}
  674. onMouseUp={() => {
  675. if (renderer.getSelection()?.getSelectedText()) return
  676. props.onClick?.()
  677. }}
  678. flexShrink={0}
  679. >
  680. <Show
  681. when={props.spinner}
  682. fallback={
  683. <text paddingLeft={3} fg={theme.textMuted}>
  684. {props.title}
  685. </text>
  686. }
  687. >
  688. <Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
  689. </Show>
  690. {props.children}
  691. <Show when={error()}>
  692. <text fg={theme.error}>{error()}</text>
  693. </Show>
  694. </box>
  695. )
  696. }
  697. function Bash(props: ToolProps) {
  698. const { theme } = useTheme()
  699. const dimensions = useTerminalDimensions()
  700. const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
  701. const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
  702. const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
  703. const [expanded, setExpanded] = createSignal(false)
  704. const maxLines = 10
  705. const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
  706. const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
  707. const limited = createMemo(() => {
  708. if (expanded() || !collapsed().overflow) return output()
  709. return collapsed().output
  710. })
  711. return (
  712. <Switch>
  713. <Match when={output()}>
  714. <BlockTool
  715. title={title()}
  716. part={props.part}
  717. spinner={props.part.state.status === "running"}
  718. onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
  719. >
  720. <box gap={1}>
  721. <text fg={theme.text}>$ {command()}</text>
  722. <text fg={theme.text}>{limited()}</text>
  723. <Show when={collapsed().overflow}>
  724. <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
  725. </Show>
  726. </box>
  727. </BlockTool>
  728. </Match>
  729. <Match when={true}>
  730. <InlineTool icon="$" pending="Writing command..." complete={command()} part={props.part}>
  731. {command()}
  732. </InlineTool>
  733. </Match>
  734. </Switch>
  735. )
  736. }
  737. function Glob(props: ToolProps) {
  738. return (
  739. <InlineTool icon="✱" pending="Finding files..." complete={toolComplete(props.part)} part={props.part}>
  740. Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
  741. <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
  742. <Show when={numberValue(props.metadata.count)}>
  743. {(count) => (
  744. <>
  745. ({count()} {count() === 1 ? "match" : "matches"})
  746. </>
  747. )}
  748. </Show>
  749. </InlineTool>
  750. )
  751. }
  752. function Read(props: ToolProps) {
  753. const { theme } = useTheme()
  754. const loaded = createMemo(() =>
  755. arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"),
  756. )
  757. return (
  758. <>
  759. <InlineTool
  760. icon="→"
  761. pending="Reading file..."
  762. complete={stringValue(props.input.filePath) ?? pendingInput(props.part)}
  763. spinner={props.part.state.status === "running"}
  764. part={props.part}
  765. >
  766. Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "}
  767. {input(props.input, ["filePath"])}
  768. </InlineTool>
  769. <For each={loaded()}>
  770. {(filepath) => (
  771. <box paddingLeft={3} flexShrink={0}>
  772. <text paddingLeft={3} fg={theme.textMuted}>
  773. ↳ Loaded {normalizePath(filepath)}
  774. </text>
  775. </box>
  776. )}
  777. </For>
  778. </>
  779. )
  780. }
  781. function Grep(props: ToolProps) {
  782. return (
  783. <InlineTool icon="✱" pending="Searching content..." complete={toolComplete(props.part)} part={props.part}>
  784. Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
  785. <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
  786. <Show when={numberValue(props.metadata.matches)}>
  787. {(matches) => (
  788. <>
  789. ({matches()} {matches() === 1 ? "match" : "matches"})
  790. </>
  791. )}
  792. </Show>
  793. </InlineTool>
  794. )
  795. }
  796. function WebFetch(props: ToolProps) {
  797. return (
  798. <InlineTool icon="%" pending="Fetching from the web..." complete={toolComplete(props.part)} part={props.part}>
  799. WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)}
  800. </InlineTool>
  801. )
  802. }
  803. function WebSearch(props: ToolProps) {
  804. const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
  805. return (
  806. <InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
  807. {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
  808. <Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
  809. </InlineTool>
  810. )
  811. }
  812. function Write(props: ToolProps) {
  813. const { theme, syntax } = useTheme()
  814. const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
  815. const content = createMemo(() => stringValue(props.input.content) ?? "")
  816. return (
  817. <Switch>
  818. <Match when={content() && props.part.state.status === "completed"}>
  819. <BlockTool title={"# Wrote " + normalizePath(filePath())} part={props.part}>
  820. <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
  821. <code
  822. conceal={false}
  823. fg={theme.text}
  824. filetype={filetype(filePath())}
  825. syntaxStyle={syntax()}
  826. content={content()}
  827. />
  828. </line_number>
  829. <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
  830. </BlockTool>
  831. </Match>
  832. <Match when={true}>
  833. <InlineTool icon="←" pending="Preparing write..." complete={filePath()} part={props.part}>
  834. Write {normalizePath(filePath())}
  835. </InlineTool>
  836. </Match>
  837. </Switch>
  838. )
  839. }
  840. function Edit(props: ToolProps) {
  841. const { theme, syntax } = useTheme()
  842. const dimensions = useTerminalDimensions()
  843. const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
  844. const diff = createMemo(() => stringValue(props.metadata.diff))
  845. return (
  846. <Switch>
  847. <Match when={diff()}>
  848. {(diff) => (
  849. <BlockTool title={"← Edit " + normalizePath(filePath())} part={props.part}>
  850. <box paddingLeft={1}>
  851. <diff
  852. diff={diff()}
  853. view={dimensions().width > 120 ? "split" : "unified"}
  854. filetype={filetype(filePath())}
  855. syntaxStyle={syntax()}
  856. showLineNumbers={true}
  857. width="100%"
  858. wrapMode="word"
  859. fg={theme.text}
  860. addedBg={theme.diffAddedBg}
  861. removedBg={theme.diffRemovedBg}
  862. contextBg={theme.diffContextBg}
  863. addedSignColor={theme.diffHighlightAdded}
  864. removedSignColor={theme.diffHighlightRemoved}
  865. lineNumberFg={theme.diffLineNumber}
  866. lineNumberBg={theme.diffContextBg}
  867. addedLineNumberBg={theme.diffAddedLineNumberBg}
  868. removedLineNumberBg={theme.diffRemovedLineNumberBg}
  869. />
  870. </box>
  871. <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
  872. </BlockTool>
  873. )}
  874. </Match>
  875. <Match when={true}>
  876. <InlineTool icon="←" pending="Preparing edit..." complete={filePath()} part={props.part}>
  877. Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })}
  878. </InlineTool>
  879. </Match>
  880. </Switch>
  881. )
  882. }
  883. function ApplyPatch(props: ToolProps) {
  884. const { theme, syntax } = useTheme()
  885. const dimensions = useTerminalDimensions()
  886. const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : [])))
  887. const fileTitle = (file: Record<string, unknown>) => {
  888. const type = stringValue(file.type)
  889. const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch"
  890. if (type === "delete") return "# Deleted " + relativePath
  891. if (type === "add") return "# Created " + relativePath
  892. if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath
  893. return "← Patched " + relativePath
  894. }
  895. return (
  896. <Switch>
  897. <Match when={files().length > 0}>
  898. <For each={files()}>
  899. {(file) => (
  900. <BlockTool title={fileTitle(file)} part={props.part}>
  901. <Show
  902. when={stringValue(file.patch)}
  903. fallback={
  904. <text fg={theme.diffRemoved}>
  905. -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"}
  906. </text>
  907. }
  908. >
  909. {(patch) => (
  910. <box paddingLeft={1}>
  911. <diff
  912. diff={patch()}
  913. view={dimensions().width > 120 ? "split" : "unified"}
  914. filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))}
  915. syntaxStyle={syntax()}
  916. showLineNumbers={true}
  917. width="100%"
  918. wrapMode="word"
  919. fg={theme.text}
  920. addedBg={theme.diffAddedBg}
  921. removedBg={theme.diffRemovedBg}
  922. contextBg={theme.diffContextBg}
  923. addedSignColor={theme.diffHighlightAdded}
  924. removedSignColor={theme.diffHighlightRemoved}
  925. lineNumberFg={theme.diffLineNumber}
  926. lineNumberBg={theme.diffContextBg}
  927. addedLineNumberBg={theme.diffAddedLineNumberBg}
  928. removedLineNumberBg={theme.diffRemovedLineNumberBg}
  929. />
  930. </box>
  931. )}
  932. </Show>
  933. </BlockTool>
  934. )}
  935. </For>
  936. </Match>
  937. <Match when={true}>
  938. <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
  939. Patch
  940. </InlineTool>
  941. </Match>
  942. </Switch>
  943. )
  944. }
  945. function TodoWrite(props: ToolProps) {
  946. const { theme } = useTheme()
  947. const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : [])))
  948. return (
  949. <Switch>
  950. <Match when={todos().length > 0 && props.part.state.status === "completed"}>
  951. <BlockTool title="# Todos" part={props.part}>
  952. <box>
  953. <For each={todos()}>
  954. {(todo) => (
  955. <text fg={theme.text}>
  956. {todoIcon(stringValue(todo.status))} {stringValue(todo.content)}
  957. </text>
  958. )}
  959. </For>
  960. </box>
  961. </BlockTool>
  962. </Match>
  963. <Match when={true}>
  964. <InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
  965. Updating todos...
  966. </InlineTool>
  967. </Match>
  968. </Switch>
  969. )
  970. }
  971. function Question(props: ToolProps) {
  972. const { theme } = useTheme()
  973. const questions = createMemo(() =>
  974. arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])),
  975. )
  976. const answers = createMemo(() => arrayValue(props.metadata.answers))
  977. return (
  978. <Switch>
  979. <Match when={answers().length > 0}>
  980. <BlockTool title="# Questions" part={props.part}>
  981. <box gap={1}>
  982. <For each={questions()}>
  983. {(question, index) => (
  984. <box>
  985. <text fg={theme.textMuted}>{stringValue(question.question)}</text>
  986. <text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
  987. </box>
  988. )}
  989. </For>
  990. </box>
  991. </BlockTool>
  992. </Match>
  993. <Match when={true}>
  994. <InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
  995. Asked {questions().length} question{questions().length === 1 ? "" : "s"}
  996. </InlineTool>
  997. </Match>
  998. </Switch>
  999. )
  1000. }
  1001. function Skill(props: ToolProps) {
  1002. return (
  1003. <InlineTool icon="→" pending="Loading skill..." complete={toolComplete(props.part)} part={props.part}>
  1004. Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}"
  1005. </InlineTool>
  1006. )
  1007. }
  1008. function Task(props: ToolProps) {
  1009. const content = createMemo(() => {
  1010. const description = stringValue(props.input.description)
  1011. if (!description) return pendingInput(props.part)
  1012. return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}`
  1013. })
  1014. return (
  1015. <InlineTool
  1016. icon="│"
  1017. spinner={props.part.state.status === "running"}
  1018. complete={toolComplete(props.part)}
  1019. pending="Delegating..."
  1020. part={props.part}
  1021. >
  1022. {content()}
  1023. </InlineTool>
  1024. )
  1025. }
  1026. function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
  1027. const { theme } = useTheme()
  1028. const errors = createMemo(() => {
  1029. if (!isRecord(props.diagnostics)) return []
  1030. const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath]
  1031. return arrayValue(value)
  1032. .flatMap((item) => (isRecord(item) ? [item] : []))
  1033. .filter((diagnostic) => diagnostic.severity === 1)
  1034. .slice(0, 3)
  1035. })
  1036. return (
  1037. <Show when={errors().length}>
  1038. <box>
  1039. <For each={errors()}>
  1040. {(diagnostic) => <text fg={theme.error}>Error {stringValue(diagnostic.message)}</text>}
  1041. </For>
  1042. </box>
  1043. </Show>
  1044. )
  1045. }
  1046. function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
  1047. return (content ?? [])
  1048. .map((item) => {
  1049. if (item.type === "text") return item.text.trim()
  1050. const source =
  1051. item.source.type === "data" ? "inline data" : item.source.type === "url" ? item.source.url : item.source.uri
  1052. return `[file ${item.name ?? source}]`
  1053. })
  1054. .filter(Boolean)
  1055. .join("\n")
  1056. }
  1057. function toolInputRecord(input: string | Record<string, unknown>) {
  1058. if (typeof input === "string") return {}
  1059. return input
  1060. }
  1061. function pendingInput(part: SessionMessageAssistantTool) {
  1062. if (part.state.status !== "pending") return ""
  1063. return part.state.input.trim()
  1064. }
  1065. function toolComplete(part: SessionMessageAssistantTool) {
  1066. if (part.state.status === "pending") return pendingInput(part)
  1067. return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running"
  1068. }
  1069. function stringValue(value: unknown) {
  1070. return typeof value === "string" ? value : undefined
  1071. }
  1072. function numberValue(value: unknown) {
  1073. return typeof value === "number" ? value : undefined
  1074. }
  1075. function arrayValue(value: unknown): unknown[] {
  1076. return Array.isArray(value) ? value : []
  1077. }
  1078. function isRecord(value: unknown): value is Record<string, unknown> {
  1079. return !!value && typeof value === "object" && !Array.isArray(value)
  1080. }
  1081. function input(input: Record<string, unknown>, omit?: string[]) {
  1082. const primitives = Object.entries(input).filter(([key, value]) => {
  1083. if (omit?.includes(key)) return false
  1084. return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
  1085. })
  1086. if (primitives.length === 0) return ""
  1087. return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
  1088. }
  1089. function normalizePath(input?: string) {
  1090. if (!input) return ""
  1091. const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input)
  1092. const relative = path.relative(process.cwd(), absolute)
  1093. if (!relative) return "."
  1094. if (!relative.startsWith("..")) return relative
  1095. return absolute
  1096. }
  1097. function filetype(input?: string) {
  1098. if (!input) return "none"
  1099. const language = LANGUAGE_EXTENSIONS[path.extname(input)]
  1100. if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
  1101. return language
  1102. }
  1103. function todoIcon(status?: string) {
  1104. if (status === "completed") return "✓"
  1105. if (status === "in_progress") return "~"
  1106. if (status === "cancelled") return "✕"
  1107. return "☐"
  1108. }
  1109. function formatAnswer(answer: unknown) {
  1110. if (!Array.isArray(answer)) return "(no answer)"
  1111. if (answer.length === 0) return "(no answer)"
  1112. return answer.filter((item): item is string => typeof item === "string").join(", ")
  1113. }
  1114. const tui: TuiPlugin = async (api) => {
  1115. api.route.register([
  1116. {
  1117. name: route,
  1118. render(input) {
  1119. const sessionID = input.params?.sessionID
  1120. if (typeof sessionID !== "string") {
  1121. return <text fg={api.theme.current.error}>Missing sessionID</text>
  1122. }
  1123. return <View api={api} sessionID={sessionID} />
  1124. },
  1125. },
  1126. ])
  1127. api.keymap.registerLayer({
  1128. commands: [
  1129. {
  1130. name: route,
  1131. title: "View v2 session messages",
  1132. category: "Debug",
  1133. namespace: "palette",
  1134. suggested: () => api.route.current.name === "session",
  1135. enabled: () => api.route.current.name === "session",
  1136. run() {
  1137. const sessionID = currentSessionID(api)
  1138. if (!sessionID) return
  1139. api.route.navigate(route, { sessionID })
  1140. api.ui.dialog.clear()
  1141. },
  1142. },
  1143. ],
  1144. })
  1145. }
  1146. const plugin: InternalTuiPlugin = {
  1147. id,
  1148. tui,
  1149. }
  1150. export default plugin