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")
+ }
+ })
+})