diff --git a/packages/codeplane/src/tui/routes/session/index.tsx b/packages/codeplane/src/tui/routes/session/index.tsx index bcfa30a84..09dbba681 100644 --- a/packages/codeplane/src/tui/routes/session/index.tsx +++ b/packages/codeplane/src/tui/routes/session/index.tsx @@ -39,6 +39,7 @@ import type { } from "@/tui/_compat/sdk-v2" import { useLocal } from "@/tui/context/local" import { Locale } from "@/tui/_compat/locale" +import { textValue } from "@/tui/util/text-value" import type { Tool } from "@/tui/_compat/tool-tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" @@ -1720,7 +1721,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las customBorderChars={SplitBorder.customBorderChars} borderColor={theme.error} > - {props.message.error?.data.message} + {textValue(props.message.error?.data.message)} @@ -2174,7 +2175,7 @@ function Shell(props: ToolProps) { onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined} > - $ {props.input.command} + $ {textValue(props.input.command)} {limited()} @@ -2186,7 +2187,7 @@ function Shell(props: ToolProps) { - {props.input.command} + {textValue(props.input.command)} @@ -2228,7 +2229,7 @@ function Write(props: ToolProps) { function Glob(props: ToolProps) { return ( - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + Glob "{textValue(props.input.pattern)}" in {normalizePath(props.input.path)} ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) @@ -2273,7 +2274,7 @@ function Read(props: ToolProps) { function Grep(props: ToolProps) { return ( - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + Grep "{textValue(props.input.pattern)}" in {normalizePath(props.input.path)} ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) @@ -2284,7 +2285,7 @@ function Grep(props: ToolProps) { function WebFetch(props: ToolProps) { return ( - WebFetch {props.input.url} + WebFetch {textValue(props.input.url)} ) } @@ -2293,7 +2294,7 @@ function WebSearch(props: ToolProps) { const metadata = props.metadata as { numResults?: number } return ( - Exa Web Search "{props.input.query}" ({metadata.numResults} results) + Exa Web Search "{textValue(props.input.query)}" ({metadata.numResults} results) ) } @@ -2553,7 +2554,7 @@ function Question(props: ToolProps) { function Skill(props: ToolProps) { return ( - Skill "{props.input.name}" + Skill "{textValue(props.input.name)}" ) } @@ -2572,7 +2573,7 @@ function Diagnostics(props: { diagnostics?: Record[] {(diagnostic) => ( - Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {diagnostic.message} + Error [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] {textValue(diagnostic.message)} )} diff --git a/packages/codeplane/src/tui/util/text-value.ts b/packages/codeplane/src/tui/util/text-value.ts new file mode 100644 index 000000000..4187802b3 --- /dev/null +++ b/packages/codeplane/src/tui/util/text-value.ts @@ -0,0 +1,26 @@ +// Coerce an arbitrary value into a string that is safe to render as a child of +// an opentui `` element. +// +// Tool `state.input` / `state.metadata` and message `error.data` are typed +// `unknown` and can hold raw, partially-streamed, or model-supplied JSON whose +// fields are objects/arrays — not always the strings the renderer assumes. When +// a non-string / non-StyledText child is mounted into a ``, opentui's +// `TextNodeRenderable.add()` throws: +// +// "TextNodeRenderable only accepts strings, TextNodeRenderable instances, or +// StyledText instances" +// +// which crashed the entire TUI when switching into a session that contained +// such a part. `textValue` guarantees a string is always returned. +export function textValue(value: unknown): string { + if (typeof value === "string") return value + if (value === null || value === undefined) return "" + if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return String(value) + try { + return JSON.stringify(value) ?? "" + } catch { + return String(value) + } +} + +export * as TextValue from "./text-value" diff --git a/packages/codeplane/test/tui/text-value.test.ts b/packages/codeplane/test/tui/text-value.test.ts new file mode 100644 index 000000000..c31b317e8 --- /dev/null +++ b/packages/codeplane/test/tui/text-value.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { textValue } from "@/tui/util/text-value" + +// Regression coverage for the TUI crash where switching into a session that +// contained a tool part with a non-string `state.input` / `state.metadata` +// field threw, in opentui's `TextNodeRenderable.add()`: +// "TextNodeRenderable only accepts strings, TextNodeRenderable instances, or +// StyledText instances" +// `textValue` is the guard the session renderer uses to coerce those `unknown` +// values before they are mounted as a `` child. The invariant is simple: +// it must ALWAYS return a string, regardless of input shape. +describe("textValue", () => { + test("passes strings through unchanged", () => { + expect(textValue("")).toBe("") + expect(textValue("ls -la")).toBe("ls -la") + expect(textValue("multi\nline")).toBe("multi\nline") + }) + + test("renders nullish as empty string (no-op child)", () => { + expect(textValue(null)).toBe("") + expect(textValue(undefined)).toBe("") + }) + + test("coerces scalar non-strings", () => { + expect(textValue(42)).toBe("42") + expect(textValue(0)).toBe("0") + expect(textValue(true)).toBe("true") + expect(textValue(false)).toBe("false") + expect(textValue(10n)).toBe("10") + }) + + test("serializes objects and arrays instead of throwing (the crash case)", () => { + // The exact class of value that used to crash the TUI: a tool input field + // arriving as a partial/streamed object or array rather than a string. + expect(textValue({ command: "ls" })).toBe('{"command":"ls"}') + expect(textValue(["a", "b"])).toBe('["a","b"]') + expect(textValue({ nested: { a: 1 } })).toBe('{"nested":{"a":1}}') + }) + + test("never throws and always returns a string for hostile inputs", () => { + const circular: Record = {} + circular.self = circular + const cases: unknown[] = [ + circular, + Symbol("x"), + () => {}, + new Map([["a", 1]]), + { toJSON() { throw new Error("boom") } }, + ] + for (const value of cases) { + const result = textValue(value) + expect(typeof result).toBe("string") + } + }) +})