Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions packages/codeplane/src/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1720,7 +1721,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.error}
>
<text fg={theme.textMuted}>{props.message.error?.data.message}</text>
<text fg={theme.textMuted}>{textValue(props.message.error?.data.message)}</text>
</box>
</Show>
<Switch>
Expand Down Expand Up @@ -2174,7 +2175,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.input.command}</text>
<text fg={theme.text}>$ {textValue(props.input.command)}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
Expand All @@ -2186,7 +2187,7 @@ function Shell(props: ToolProps<typeof ShellTool>) {
</Match>
<Match when={true}>
<InlineTool icon="$" pending="Writing command..." complete={props.input.command} part={props.part}>
{props.input.command}
{textValue(props.input.command)}
</InlineTool>
</Match>
</Switch>
Expand Down Expand Up @@ -2228,7 +2229,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
function Glob(props: ToolProps<typeof GlobTool>) {
return (
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
Glob "{textValue(props.input.pattern)}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.count}>
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
</Show>
Expand Down Expand Up @@ -2273,7 +2274,7 @@ function Read(props: ToolProps<typeof ReadTool>) {
function Grep(props: ToolProps<typeof GrepTool>) {
return (
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
Grep "{textValue(props.input.pattern)}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.matches}>
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
</Show>
Expand All @@ -2284,7 +2285,7 @@ function Grep(props: ToolProps<typeof GrepTool>) {
function WebFetch(props: ToolProps<typeof WebFetchTool>) {
return (
<InlineTool icon="%" pending="Fetching from the web..." complete={props.input.url} part={props.part}>
WebFetch {props.input.url}
WebFetch {textValue(props.input.url)}
</InlineTool>
)
}
Expand All @@ -2293,7 +2294,7 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const metadata = props.metadata as { numResults?: number }
return (
<InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
Exa Web Search "{props.input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
Exa Web Search "{textValue(props.input.query)}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
</InlineTool>
)
}
Expand Down Expand Up @@ -2553,7 +2554,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
function Skill(props: ToolProps<typeof SkillTool>) {
return (
<InlineTool icon="→" pending="Loading skill..." complete={props.input.name} part={props.part}>
Skill "{props.input.name}"
Skill "{textValue(props.input.name)}"
</InlineTool>
)
}
Expand All @@ -2572,7 +2573,7 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
<For each={errors()}>
{(diagnostic) => (
<text fg={theme.error}>
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)}
</text>
)}
</For>
Expand Down
26 changes: 26 additions & 0 deletions packages/codeplane/src/tui/util/text-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Coerce an arbitrary value into a string that is safe to render as a child of
// an opentui `<text>` 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 `<text>`, 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"
55 changes: 55 additions & 0 deletions packages/codeplane/test/tui/text-value.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<text>` 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<string, unknown> = {}
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")
}
})
})
Loading