inline-tool-wrap-snapshot.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { createSignal, For, Show } from "solid-js"
  3. import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
  4. import { testRender, type JSX } from "@opentui/solid"
  5. import {
  6. formatCompletedSubagentDetail,
  7. formatSubagentRetry,
  8. formatSubagentTitle,
  9. formatSubagentToolcalls,
  10. InlineToolRow,
  11. parseApplyPatchFiles,
  12. parseDiagnostics,
  13. parseQuestionAnswers,
  14. parseQuestions,
  15. parseTodos,
  16. alwaysSeparate,
  17. toolDisplay,
  18. } from "../../../src/routes/session"
  19. let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
  20. afterEach(() => {
  21. testSetup?.renderer.destroy()
  22. testSetup = undefined
  23. })
  24. type ToolFixture = { icon: string; label: string; error?: string }
  25. const tools: readonly ToolFixture[] = [
  26. {
  27. icon: "✱",
  28. label:
  29. 'Grep "KIRINCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/kirincode/src (151 matches)',
  30. },
  31. {
  32. icon: "✱",
  33. label: 'Glob "**/*db*" in packages/opencode (6 matches)',
  34. },
  35. {
  36. icon: "→",
  37. label: "Read packages/kirincode/src/storage/db.ts [offset=1, limit=130]",
  38. },
  39. {
  40. icon: "→",
  41. label: "Read packages/kirincode/src/index.ts [offset=1, limit=100]",
  42. error: "No LSP server available for this file type.",
  43. },
  44. {
  45. icon: "✱",
  46. label:
  47. 'Grep "export const KIRINCODE_DB|KIRINCODE_DB|KIRINCODE_DEV|Global\\.Path\\.data|data =" in packages/kirincode/src (115 matches)',
  48. },
  49. ] as const
  50. function ShellOutput() {
  51. return (
  52. <box
  53. ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
  54. marginTop={1}
  55. paddingTop={1}
  56. paddingBottom={1}
  57. paddingLeft={2}
  58. gap={1}
  59. >
  60. <box gap={1}>
  61. <text>$ ls</text>
  62. <text>file.ts</text>
  63. </box>
  64. </box>
  65. )
  66. }
  67. function UserMessage() {
  68. return (
  69. <box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
  70. <box paddingTop={1} paddingBottom={1} paddingLeft={2}>
  71. <text>Check whether the next tool remains separated.</text>
  72. </box>
  73. </box>
  74. )
  75. }
  76. function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
  77. return (
  78. <box flexDirection="column" width={72}>
  79. <box flexDirection="column">
  80. {props.before === "shell" && <ShellOutput />}
  81. {props.before === "user" && <UserMessage />}
  82. <For each={tools}>
  83. {(item) => (
  84. <InlineToolRow
  85. icon={item.icon}
  86. complete={true}
  87. pending=""
  88. failed={Boolean(item.error)}
  89. error={item.error}
  90. errorExpanded={props.errorExpanded}
  91. >
  92. {item.label}
  93. </InlineToolRow>
  94. )}
  95. </For>
  96. </box>
  97. </box>
  98. )
  99. }
  100. function TaskRowsFixture() {
  101. return (
  102. <box flexDirection="column" width={72}>
  103. <InlineToolRow icon="✱" complete={true} pending="">
  104. Grep "Task" (2 matches)
  105. </InlineToolRow>
  106. <InlineToolRow icon="⠙" complete={true} pending="" separate={true}>
  107. Explore Task — Inspect active task spacing
  108. </InlineToolRow>
  109. <InlineToolRow icon="✓" complete={true} pending="" separate={true}>
  110. {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
  111. </InlineToolRow>
  112. <InlineToolRow icon="→" complete={true} pending="">
  113. Read src/cli/cmd/tui/routes/session/index.tsx
  114. </InlineToolRow>
  115. </box>
  116. )
  117. }
  118. function LoadedReadBeforeTaskFixture() {
  119. return (
  120. <box flexDirection="column" width={72}>
  121. <InlineToolRow icon="→" complete={true} pending="">
  122. Read src/cli/cmd/tui/routes/session/index.tsx
  123. </InlineToolRow>
  124. <box paddingLeft={3}>
  125. <text paddingLeft={3}>↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
  126. </box>
  127. <InlineToolRow icon="✓" complete={true} pending="" separate={true}>
  128. {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
  129. </InlineToolRow>
  130. </box>
  131. )
  132. }
  133. function AssistantSummaryBeforeInlineFixture() {
  134. return (
  135. <box flexDirection="column" width={72}>
  136. <box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
  137. <text>▣ Build · Little Frank · 53.1s</text>
  138. </box>
  139. <InlineToolRow icon="✓" complete={true} pending="">
  140. {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
  141. </InlineToolRow>
  142. </box>
  143. )
  144. }
  145. function AssistantErrorBeforeInlineFixture() {
  146. return (
  147. <box flexDirection="column" width={72}>
  148. <box
  149. ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
  150. border={["left"]}
  151. paddingTop={1}
  152. paddingBottom={1}
  153. paddingLeft={2}
  154. >
  155. <text>Managed inference requires an active Member plan</text>
  156. </box>
  157. <InlineToolRow icon="✓" complete={true} pending="">
  158. {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
  159. </InlineToolRow>
  160. </box>
  161. )
  162. }
  163. function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
  164. return (
  165. <scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
  166. <box height={1}>
  167. <text>First row</text>
  168. </box>
  169. <box height={1}>
  170. <text>Second row</text>
  171. </box>
  172. <Show when={props.separated}>
  173. <box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
  174. <text>Assistant text</text>
  175. </box>
  176. </Show>
  177. <InlineToolRow icon="→" complete={true} pending="">
  178. Read src/cli/cmd/tui/routes/session/index.tsx
  179. </InlineToolRow>
  180. </scrollbox>
  181. )
  182. }
  183. function FailedPendingToolFixture() {
  184. return (
  185. <InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
  186. Patch
  187. </InlineToolRow>
  188. )
  189. }
  190. function FailedCompleteToolFixture() {
  191. return (
  192. <InlineToolRow icon="→" complete={true} pending="Reading file..." failed={true} failure="Read failed">
  193. Read src/index.ts
  194. </InlineToolRow>
  195. )
  196. }
  197. async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
  198. testSetup = await testRender(component, options)
  199. await testSetup.renderOnce()
  200. await testSetup.renderOnce()
  201. return testSetup
  202. .captureCharFrame()
  203. .split("\n")
  204. .map((line) => line.trimEnd())
  205. .join("\n")
  206. .trimEnd()
  207. }
  208. describe("TUI inline tool wrapping", () => {
  209. test("falls back for unknown tool names", () => {
  210. expect(toolDisplay("bash")).toBe("bash")
  211. expect(toolDisplay("plugin_tool")).toBe("generic")
  212. })
  213. test("replaces pending copy when a tool fails before completion", async () => {
  214. const frame = await renderFrame(() => <FailedPendingToolFixture />, { width: 72, height: 3 })
  215. expect(frame).toContain("Patch failed")
  216. expect(frame).not.toContain("Preparing patch")
  217. })
  218. test("preserves useful completed copy when a tool fails", async () => {
  219. const frame = await renderFrame(() => <FailedCompleteToolFixture />, { width: 72, height: 3 })
  220. expect(frame).toContain("Read src/index.ts")
  221. expect(frame).not.toContain("Read failed")
  222. })
  223. test("filters malformed nested tool wire data", () => {
  224. expect(
  225. parseApplyPatchFiles([
  226. null,
  227. { type: "add" },
  228. { type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 },
  229. ]),
  230. ).toEqual([
  231. { type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined },
  232. ])
  233. expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([
  234. { status: "pending", content: "Safe" },
  235. ])
  236. expect(parseQuestions([{}, { question: 1 }, { question: "Continue?" }])).toEqual([{ question: "Continue?" }])
  237. expect(parseQuestionAnswers([null, ["yes", 1], "no"])).toEqual([[], ["yes"], []])
  238. expect(parseQuestionAnswers({})).toBeUndefined()
  239. })
  240. test("ignores diagnostics with malformed nested ranges", () => {
  241. expect(
  242. parseDiagnostics(
  243. {
  244. "a.ts": [
  245. { severity: 1, message: "missing range" },
  246. { severity: 1, message: "bad line", range: { start: { line: "0", character: 1 } } },
  247. { severity: 1, message: "valid", range: { start: { line: 2, character: 3 } } },
  248. ],
  249. },
  250. "a.ts",
  251. ),
  252. ).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
  253. })
  254. test("formats completed subagent toolcall details", () => {
  255. expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
  256. expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
  257. expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
  258. expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
  259. })
  260. test("keeps background state attached to the subagent identity", () => {
  261. expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer")
  262. expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
  263. "Explore Task (background) — Inspect renderer",
  264. )
  265. })
  266. test("keeps retry status ahead of wrapping messages", () => {
  267. expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
  268. })
  269. test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
  270. expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
  271. })
  272. test("snapshots expanded tool errors under the tool text", async () => {
  273. expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
  274. })
  275. test("keeps separation after a shell output block", async () => {
  276. expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
  277. })
  278. test("keeps separation after a padded user message", async () => {
  279. expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
  280. })
  281. test("separates after a multi-line task row", async () => {
  282. expect(await renderFrame(() => <TaskRowsFixture />, { width: 72, height: 10 })).toMatchSnapshot()
  283. })
  284. test("separates a task row from a preceding inline detail", async () => {
  285. expect(await renderFrame(() => <LoadedReadBeforeTaskFixture />, { width: 72, height: 8 })).toMatchSnapshot()
  286. })
  287. test("separates an inline row from the previous assistant summary", async () => {
  288. expect(await renderFrame(() => <AssistantSummaryBeforeInlineFixture />, { width: 72, height: 5 })).toMatchSnapshot()
  289. })
  290. test("separates an inline row from the previous assistant error", async () => {
  291. expect(await renderFrame(() => <AssistantErrorBeforeInlineFixture />, { width: 72, height: 7 })).toMatchSnapshot()
  292. })
  293. test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
  294. const [separated, setSeparated] = createSignal(false)
  295. let scroll: ScrollBoxRenderable | undefined
  296. testSetup = await testRender(
  297. () => <StickyScrollFixture separated={separated()} scroll={(value) => (scroll = value)} />,
  298. {
  299. width: 72,
  300. height: 3,
  301. },
  302. )
  303. await testSetup.renderOnce()
  304. expect(scroll?.scrollHeight).toBe(3)
  305. expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
  306. setSeparated(true)
  307. await testSetup.renderOnce()
  308. expect(scroll?.scrollHeight).toBe(5)
  309. expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
  310. setSeparated(false)
  311. await testSetup.renderOnce()
  312. expect(scroll?.scrollHeight).toBe(3)
  313. expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
  314. })
  315. })