| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- import { afterEach, describe, expect, test } from "bun:test"
- import { createSignal, For, Show } from "solid-js"
- import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
- import { testRender, type JSX } from "@opentui/solid"
- import {
- formatCompletedSubagentDetail,
- formatSubagentRetry,
- formatSubagentTitle,
- formatSubagentToolcalls,
- InlineToolRow,
- parseApplyPatchFiles,
- parseDiagnostics,
- parseQuestionAnswers,
- parseQuestions,
- parseTodos,
- alwaysSeparate,
- toolDisplay,
- } from "../../../src/routes/session"
- let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
- afterEach(() => {
- testSetup?.renderer.destroy()
- testSetup = undefined
- })
- type ToolFixture = { icon: string; label: string; error?: string }
- const tools: readonly ToolFixture[] = [
- {
- icon: "✱",
- label:
- 'Grep "KIRINCODE.*DB|database|sqlite|drizzle|dev.*db|data.*dir|xdg|APPDATA" in packages/kirincode/src (151 matches)',
- },
- {
- icon: "✱",
- label: 'Glob "**/*db*" in packages/opencode (6 matches)',
- },
- {
- icon: "→",
- label: "Read packages/kirincode/src/storage/db.ts [offset=1, limit=130]",
- },
- {
- icon: "→",
- label: "Read packages/kirincode/src/index.ts [offset=1, limit=100]",
- error: "No LSP server available for this file type.",
- },
- {
- icon: "✱",
- label:
- 'Grep "export const KIRINCODE_DB|KIRINCODE_DB|KIRINCODE_DEV|Global\\.Path\\.data|data =" in packages/kirincode/src (115 matches)',
- },
- ] as const
- function ShellOutput() {
- return (
- <box
- ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
- marginTop={1}
- paddingTop={1}
- paddingBottom={1}
- paddingLeft={2}
- gap={1}
- >
- <box gap={1}>
- <text>$ ls</text>
- <text>file.ts</text>
- </box>
- </box>
- )
- }
- function UserMessage() {
- return (
- <box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
- <box paddingTop={1} paddingBottom={1} paddingLeft={2}>
- <text>Check whether the next tool remains separated.</text>
- </box>
- </box>
- )
- }
- function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
- return (
- <box flexDirection="column" width={72}>
- <box flexDirection="column">
- {props.before === "shell" && <ShellOutput />}
- {props.before === "user" && <UserMessage />}
- <For each={tools}>
- {(item) => (
- <InlineToolRow
- icon={item.icon}
- complete={true}
- pending=""
- failed={Boolean(item.error)}
- error={item.error}
- errorExpanded={props.errorExpanded}
- >
- {item.label}
- </InlineToolRow>
- )}
- </For>
- </box>
- </box>
- )
- }
- function TaskRowsFixture() {
- return (
- <box flexDirection="column" width={72}>
- <InlineToolRow icon="✱" complete={true} pending="">
- Grep "Task" (2 matches)
- </InlineToolRow>
- <InlineToolRow icon="⠙" complete={true} pending="" separate={true}>
- Explore Task — Inspect active task spacing
- </InlineToolRow>
- <InlineToolRow icon="✓" complete={true} pending="" separate={true}>
- {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
- </InlineToolRow>
- <InlineToolRow icon="→" complete={true} pending="">
- Read src/cli/cmd/tui/routes/session/index.tsx
- </InlineToolRow>
- </box>
- )
- }
- function LoadedReadBeforeTaskFixture() {
- return (
- <box flexDirection="column" width={72}>
- <InlineToolRow icon="→" complete={true} pending="">
- Read src/cli/cmd/tui/routes/session/index.tsx
- </InlineToolRow>
- <box paddingLeft={3}>
- <text paddingLeft={3}>↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
- </box>
- <InlineToolRow icon="✓" complete={true} pending="" separate={true}>
- {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
- </InlineToolRow>
- </box>
- )
- }
- function AssistantSummaryBeforeInlineFixture() {
- return (
- <box flexDirection="column" width={72}>
- <box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
- <text>▣ Build · Little Frank · 53.1s</text>
- </box>
- <InlineToolRow icon="✓" complete={true} pending="">
- {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
- </InlineToolRow>
- </box>
- )
- }
- function AssistantErrorBeforeInlineFixture() {
- return (
- <box flexDirection="column" width={72}>
- <box
- ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
- border={["left"]}
- paddingTop={1}
- paddingBottom={1}
- paddingLeft={2}
- >
- <text>Managed inference requires an active Member plan</text>
- </box>
- <InlineToolRow icon="✓" complete={true} pending="">
- {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
- </InlineToolRow>
- </box>
- )
- }
- function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
- return (
- <scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
- <box height={1}>
- <text>First row</text>
- </box>
- <box height={1}>
- <text>Second row</text>
- </box>
- <Show when={props.separated}>
- <box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
- <text>Assistant text</text>
- </box>
- </Show>
- <InlineToolRow icon="→" complete={true} pending="">
- Read src/cli/cmd/tui/routes/session/index.tsx
- </InlineToolRow>
- </scrollbox>
- )
- }
- function FailedPendingToolFixture() {
- return (
- <InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
- Patch
- </InlineToolRow>
- )
- }
- function FailedCompleteToolFixture() {
- return (
- <InlineToolRow icon="→" complete={true} pending="Reading file..." failed={true} failure="Read failed">
- Read src/index.ts
- </InlineToolRow>
- )
- }
- async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
- testSetup = await testRender(component, options)
- await testSetup.renderOnce()
- await testSetup.renderOnce()
- return testSetup
- .captureCharFrame()
- .split("\n")
- .map((line) => line.trimEnd())
- .join("\n")
- .trimEnd()
- }
- describe("TUI inline tool wrapping", () => {
- test("falls back for unknown tool names", () => {
- expect(toolDisplay("bash")).toBe("bash")
- expect(toolDisplay("plugin_tool")).toBe("generic")
- })
- test("replaces pending copy when a tool fails before completion", async () => {
- const frame = await renderFrame(() => <FailedPendingToolFixture />, { width: 72, height: 3 })
- expect(frame).toContain("Patch failed")
- expect(frame).not.toContain("Preparing patch")
- })
- test("preserves useful completed copy when a tool fails", async () => {
- const frame = await renderFrame(() => <FailedCompleteToolFixture />, { width: 72, height: 3 })
- expect(frame).toContain("Read src/index.ts")
- expect(frame).not.toContain("Read failed")
- })
- test("filters malformed nested tool wire data", () => {
- expect(
- parseApplyPatchFiles([
- null,
- { type: "add" },
- { type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 },
- ]),
- ).toEqual([
- { type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined },
- ])
- expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([
- { status: "pending", content: "Safe" },
- ])
- expect(parseQuestions([{}, { question: 1 }, { question: "Continue?" }])).toEqual([{ question: "Continue?" }])
- expect(parseQuestionAnswers([null, ["yes", 1], "no"])).toEqual([[], ["yes"], []])
- expect(parseQuestionAnswers({})).toBeUndefined()
- })
- test("ignores diagnostics with malformed nested ranges", () => {
- expect(
- parseDiagnostics(
- {
- "a.ts": [
- { severity: 1, message: "missing range" },
- { severity: 1, message: "bad line", range: { start: { line: "0", character: 1 } } },
- { severity: 1, message: "valid", range: { start: { line: 2, character: 3 } } },
- ],
- },
- "a.ts",
- ),
- ).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
- })
- test("formats completed subagent toolcall details", () => {
- expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
- expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
- expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
- expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
- })
- test("keeps background state attached to the subagent identity", () => {
- expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Task — Inspect renderer")
- expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
- "Explore Task (background) — Inspect renderer",
- )
- })
- test("keeps retry status ahead of wrapping messages", () => {
- expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
- })
- test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
- expect(await renderFrame(() => <Fixture />, { width: 72, height: 12 })).toMatchSnapshot()
- })
- test("snapshots expanded tool errors under the tool text", async () => {
- expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
- })
- test("keeps separation after a shell output block", async () => {
- expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
- })
- test("keeps separation after a padded user message", async () => {
- expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
- })
- test("separates after a multi-line task row", async () => {
- expect(await renderFrame(() => <TaskRowsFixture />, { width: 72, height: 10 })).toMatchSnapshot()
- })
- test("separates a task row from a preceding inline detail", async () => {
- expect(await renderFrame(() => <LoadedReadBeforeTaskFixture />, { width: 72, height: 8 })).toMatchSnapshot()
- })
- test("separates an inline row from the previous assistant summary", async () => {
- expect(await renderFrame(() => <AssistantSummaryBeforeInlineFixture />, { width: 72, height: 5 })).toMatchSnapshot()
- })
- test("separates an inline row from the previous assistant error", async () => {
- expect(await renderFrame(() => <AssistantErrorBeforeInlineFixture />, { width: 72, height: 7 })).toMatchSnapshot()
- })
- test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
- const [separated, setSeparated] = createSignal(false)
- let scroll: ScrollBoxRenderable | undefined
- testSetup = await testRender(
- () => <StickyScrollFixture separated={separated()} scroll={(value) => (scroll = value)} />,
- {
- width: 72,
- height: 3,
- },
- )
- await testSetup.renderOnce()
- expect(scroll?.scrollHeight).toBe(3)
- expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
- setSeparated(true)
- await testSetup.renderOnce()
- expect(scroll?.scrollHeight).toBe(5)
- expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
- setSeparated(false)
- await testSetup.renderOnce()
- expect(scroll?.scrollHeight).toBe(3)
- expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
- })
- })
|