feat(opentui-v2): Phase 2b-i — ordered parts + inline tool render

An assistant turn is now ONE ordered parts[] (text/reasoning/tool) instead of a
flat string, so tool calls render INLINE between text blocks rather than dumped
as separate rows below (spec §7 — the "dump-below" bug opencode's sync-v2 avoids).

- logic/store.ts: Part discriminated union + reducer rework. message.delta
  appends to the open text part (or opens one); tool.start pushes a running tool
  part; tool.complete matches by tool_id and updates that part IN PLACE (state,
  envelope-stripped resultText, summary, error, lineCount); reasoning.delta
  accumulates a reasoning part. User/system rows stay flat text; settled/resumed
  assistant rows fall back to text.
- logic/toolOutput.ts: ported pure helpers — stripToolEnvelope (unwrap
  {output,exit_code}, append [exit N]/[error] suffix) + collapseToolOutput +
  truncate.
- view/messageLine.tsx: <For>+<Switch> dispatch by part.type with stable id keys.
- view/toolPart.tsx: two-tier render — inline one-liner (≤1 output line) or a
  capped left-bar block (TOOL_MAX_LINES, "… +N more", click-to-expand) keyed off
  the theme; reactive width via useTerminalDimensions.

Verified: bun run check green (23 tests / 5 files / 64 expects) — store
interleave/in-place/reasoning, a frame test asserting the tool renders inline +
envelope stripped, and toolOutput unit tests. Live tmux: a terminal-tool prompt
rendered " terminal" with its alpha/beta output inline between the assistant's
text parts; Ctrl+C clean, no orphan. Smoke P2b + parity matrix updated. Native
<markdown> for text parts is the next slice (2b-ii).
This commit is contained in:
alt-glitch 2026-06-08 14:42:24 +00:00
parent 53b37463c4
commit b72ac77783
7 changed files with 494 additions and 44 deletions

View file

@ -1,25 +1,48 @@
/**
* Session/message store the SOLID side (spec v4 §1, §7.5). Plain `createStore`
* Session/message store the SOLID side (spec v4 §1, §7). Plain `createStore`
* + an `apply(event)` reducer, à la opencode `context/sync-v2.tsx`. NOT Effect.
* The boundary calls `apply` with already-decoded `GatewayEvent`s via
* GatewayService.subscribe.
*
* Phase 1 scope:
* - streaming text reducer (start/delta/complete; prefer `text` over `rendered`)
* - reactive `theme` updated from gateway.ready{skin} / skin.changed fromSkin
* - LRU id-dedup (events carrying a stable id applied at most once)
* - hydrate-while-buffering hook (resume): snapshot replaces history, live
* events that arrive mid-hydrate are buffered then replayed
* Phase 2 grows the message model into ordered parts (text/tool/reasoning, §7).
* Phase 2b: an assistant turn is ONE ordered `parts[]` of a discriminated union
* (text / reasoning / tool), so tool calls render INLINE between text blocks
* instead of dumped as separate rows below (§7 the "dump-below" bug). Tools are
* matched startcomplete by `tool_id`; `tool.complete` updates that part IN PLACE.
* User/system rows stay flat `text` (no parts). Carried from Phase 1: streaming
* concat (prefer `payload.text`), skintheme, LRU dedup, hydrate-while-buffering.
*/
import { createStore, produce } from 'solid-js/store'
import type { GatewayEvent, GatewaySkinDecoded } from '../boundary/schema/GatewayEvent.ts'
import { stripToolEnvelope } from './toolOutput.ts'
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
/** A tool call inside an assistant turn (matched start↔complete by `id`=tool_id). */
export interface ToolPartState {
type: 'tool'
id: string
name: string
state: 'running' | 'complete'
/** Envelope-stripped output (multi-line → block render; the view caps it). */
resultText?: string
/** Short one-line status when there's no substantial output. */
summary?: string
error?: string
lineCount?: number
}
/** One ordered piece of an assistant turn (§7). */
export type Part =
| { type: 'text'; id: string; text: string }
| { type: 'reasoning'; id: string; text: string }
| ToolPartState
export interface Message {
readonly role: 'user' | 'assistant' | 'system'
/** Flat body for user/system rows (and settled/resumed assistant rows). */
text: string
/** Ordered parts for a live assistant turn; absent for user/system. */
parts?: Part[]
streaming?: boolean
}
@ -31,6 +54,12 @@ export interface StoreState {
const LRU_LIMIT = 1000
/** Read a string field off an unknown payload record (no `any`, no cast). */
function readStr(payload: { readonly [k: string]: unknown }, key: string): string | undefined {
const v = payload[key]
return typeof v === 'string' ? v : undefined
}
export function createSessionStore() {
const [state, setState] = createStore<StoreState>({
ready: false,
@ -38,6 +67,11 @@ export function createSessionStore() {
theme: DEFAULT_THEME
})
// Monotonic part id (stable `key` per part so a new tool part below a streaming
// text part doesn't remount/re-tokenize it).
let partSeq = 0
const nextId = () => `p${++partSeq}`
// LRU id-dedup: events that carry a stable id are applied at most once.
const applied = new Set<string>()
function duplicate(id: string | undefined): boolean {
@ -59,6 +93,43 @@ export function createSessionStore() {
setState('theme', themeFromSkin(skin))
}
// ── parts helpers (operate on a draft message inside produce) ───────────
function appendPart(m: Message, type: 'text' | 'reasoning', text: string): void {
const parts = (m.parts ??= [])
const last = parts[parts.length - 1]
if (last && last.type === type) last.text += text
else parts.push({ type, id: nextId(), text })
}
/** The live (last) assistant message, optionally only when still streaming. */
function liveAssistant(draft: StoreState, streamingOnly = false): Message | undefined {
const last = draft.messages[draft.messages.length - 1]
if (last && last.role === 'assistant' && (!streamingOnly || last.streaming)) return last
return undefined
}
/** Ensure there's an open assistant turn to attach parts to (tool/reasoning). */
function ensureAssistant(draft: StoreState): Message {
const live = liveAssistant(draft, true)
if (live) return live
const created: Message = { role: 'assistant', text: '', parts: [], streaming: true }
draft.messages.push(created)
return created
}
/** Find a tool part by id, scanning recent assistant turns (complete may land late). */
function findToolPart(draft: StoreState, id: string): ToolPartState | undefined {
for (let i = draft.messages.length - 1; i >= 0; i--) {
const parts = draft.messages[i]?.parts
if (!parts) continue
for (let j = parts.length - 1; j >= 0; j--) {
const p = parts[j]
if (p && p.type === 'tool' && p.id === id) return p
}
}
return undefined
}
/** Push a user message (composer submit). */
function pushUser(text: string) {
setState(
@ -89,7 +160,7 @@ export function createSessionStore() {
case 'message.start':
setState(
produce(draft => {
draft.messages.push({ role: 'assistant', text: '', streaming: true })
draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true })
})
)
break
@ -99,8 +170,8 @@ export function createSessionStore() {
if (!text) break
setState(
produce(draft => {
const live = draft.messages[draft.messages.length - 1]
if (live && live.role === 'assistant' && live.streaming) live.text += text
const live = liveAssistant(draft, true)
if (live) appendPart(live, 'text', text)
})
)
break
@ -108,17 +179,67 @@ export function createSessionStore() {
case 'message.complete':
setState(
produce(draft => {
const live = draft.messages[draft.messages.length - 1]
if (live && live.role === 'assistant' && live.streaming) {
const finalText = event.payload?.text
if (finalText) live.text = finalText
live.streaming = false
}
const live = liveAssistant(draft, true)
if (!live) return
// If no deltas arrived (complete-only gateways), seed the full text once.
const finalText = event.payload?.text
const hasText = (live.parts ?? []).some(p => p.type === 'text' && p.text.length > 0)
if (finalText && !hasText) appendPart(live, 'text', finalText)
live.streaming = false
})
)
break
// Other event types (tools, prompts, chrome, subagents) are reduced in
// later phases; unhandled members are intentionally ignored here.
case 'reasoning.delta':
case 'thinking.delta': {
const text = event.payload?.text ?? ''
if (!text) break
setState(
produce(draft => {
appendPart(ensureAssistant(draft), 'reasoning', text)
})
)
break
}
case 'tool.start': {
const id = readStr(event.payload, 'tool_id')
if (!id) break
const name = readStr(event.payload, 'name') ?? 'tool'
setState(
produce(draft => {
const live = ensureAssistant(draft)
;(live.parts ??= []).push({ type: 'tool', id, name, state: 'running' })
})
)
break
}
case 'tool.complete': {
const id = readStr(event.payload, 'tool_id')
if (!id) break
const name = readStr(event.payload, 'name')
const error = readStr(event.payload, 'error')
const summary = readStr(event.payload, 'summary')
const resultText = stripToolEnvelope(readStr(event.payload, 'result_text') ?? summary ?? '')
const lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0
setState(
produce(draft => {
let part = findToolPart(draft, id)
if (!part) {
// complete without a matching start — append a settled tool part.
part = { type: 'tool', id, name: name ?? 'tool', state: 'running' }
;(ensureAssistant(draft).parts ??= []).push(part)
}
part.state = 'complete'
part.lineCount = lineCount
if (name) part.name = name
if (resultText) part.resultText = resultText
if (summary) part.summary = summary
if (error) part.error = error
})
)
break
}
// Other event types (prompts, chrome, subagents) are reduced in later phases;
// unhandled members are intentionally ignored here.
}
}

View file

@ -0,0 +1,60 @@
/**
* Pure text-shaping helpers for compact tool-result rendering (spec v4 §7 / §8).
* No OpenTUI/Solid imports just string work, trivially unit-testable. Ported
* 1:1 from the React build's `engine/toolOutput.ts` (itself mirroring opencode's
* `util/collapse-tool-output.ts` + the gateway tool-result JSON-envelope unwrap).
*/
/** Result of collapsing tool output for the block render. */
export interface Collapsed {
lines: string[]
/** How many trailing lines were dropped (0 when nothing was hidden). */
hiddenLines: number
truncated: boolean
}
/** Truncate a single line to `width` columns, adding an ellipsis when cut. */
export function truncate(s: string, width: number): string {
const w = Math.max(1, width)
return s.length > w ? s.slice(0, Math.max(1, w - 1)) + '…' : s
}
/**
* Unwrap the gateway's tool-result JSON envelope so the view shows the actual
* output, not the wrapper. Many tools return
* `{"output": "...", "exit_code": 0, "error": null}`. If `raw` parses to such an
* object, return its `output` (plus a compact error/exit suffix when the command
* failed); otherwise return `raw` unchanged. (Gotcha §8 strip the envelope.)
*/
export function stripToolEnvelope(raw: string): string {
const s = (raw ?? '').trim()
if (!s.startsWith('{')) return raw ?? ''
try {
const parsed: unknown = JSON.parse(s)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'output' in parsed) {
const obj = parsed as Record<string, unknown>
let out = typeof obj.output === 'string' ? obj.output : JSON.stringify(obj.output, null, 2)
const err = obj.error
const code = obj.exit_code
if (typeof err === 'string' && err) out += `\n[error] ${err}`
else if (typeof code === 'number' && code !== 0) out += `\n[exit ${code}]`
return out
}
} catch {
// not JSON — fall through and return raw
}
return raw ?? ''
}
/**
* Collapse text to at most `maxLines` lines, each capped to `width` columns. The
* view renders an overflow marker from `hiddenLines`; this stays pure (no marker).
*/
export function collapseToolOutput(text: string, maxLines: number, width: number): Collapsed {
const all = (text ?? '').replace(/\s+$/, '').split('\n')
const limit = Math.max(1, maxLines)
const lines = all.slice(0, limit).map(l => truncate(l, width))
const hiddenLines = Math.max(0, all.length - lines.length)
return { hiddenLines, lines, truncated: hiddenLines > 0 }
}

View file

@ -55,4 +55,31 @@ describe('App render (Phase 1, themed)', () => {
expect(frame).toContain('Zephyr')
expect(frame).not.toContain('Hermes Agent')
})
test('renders an inline tool part between text (ordered parts §7)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'Listing files:' } })
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal' } })
store.apply({
type: 'tool.complete',
payload: { tool_id: 't1', result_text: '{"output":"alpha.txt\\nbeta.txt","exit_code":0}' }
})
store.apply({ type: 'message.complete' })
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ width: 60, height: 16 }
)
expect(frame).toContain('Listing files:') // text part
expect(frame).toContain('terminal') // tool name (inline, between text blocks)
expect(frame).toContain('alpha.txt') // envelope-stripped output, block-rendered
expect(frame).not.toContain('exit_code') // the {output,exit_code} envelope is stripped
})
})

View file

@ -1,13 +1,15 @@
/**
* Phase 1 store test (spec v4 §5 Layer 3). Pure data behavior of the grown
* reducer: skin theme, LRU dedup, hydrate-while-buffering for resume.
* Store test (spec v4 §5 Layer 3). Pure data behavior of the reducer: skin
* theme, LRU dedup, hydrate-while-buffering (Phase 1); and the Phase 2b ordered
* `parts[]` model text/tool interleave in one turn, tool startcomplete matched
* by id and updated IN PLACE, `{output,exit_code}` envelope stripped.
*/
import { describe, expect, test } from 'bun:test'
import { DEFAULT_THEME } from '../logic/theme.ts'
import { createSessionStore, type Message } from '../logic/store.ts'
describe('session store (Phase 1)', () => {
describe('session store — theming / dedup / hydrate (Phase 1)', () => {
test('gateway.ready{skin} re-themes; default before', () => {
const store = createSessionStore()
expect(store.state.theme.brand.name).toBe(DEFAULT_THEME.brand.name)
@ -52,6 +54,68 @@ describe('session store (Phase 1)', () => {
// snapshot (2) + the buffered live assistant turn (1) replayed after
expect(store.state.messages.length).toBe(3)
expect(store.state.messages[0]!.text).toBe('old q')
expect(store.state.messages[2]!.text).toBe('live!')
// the streamed assistant text now lives in an ordered text part
expect(store.state.messages[2]!.parts?.[0]).toMatchObject({ type: 'text', text: 'live!' })
})
})
describe('session store — ordered parts (Phase 2b)', () => {
test('interleaves text → tool → text as ordered parts in one assistant turn', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'before ' } })
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal' } })
// result_text is the {output,exit_code} JSON envelope — the store strips it.
store.apply({
type: 'tool.complete',
payload: { tool_id: 't1', result_text: '{"output":"hello\\nworld","exit_code":0}' }
})
store.apply({ type: 'message.delta', payload: { text: 'after' } })
store.apply({ type: 'message.complete' })
const msg = store.state.messages.at(-1)!
expect(msg.role).toBe('assistant')
expect(msg.streaming).toBe(false)
const parts = msg.parts ?? []
expect(parts.map(p => p.type)).toEqual(['text', 'tool', 'text'])
expect(parts[0]).toMatchObject({ type: 'text', text: 'before ' })
expect(parts[2]).toMatchObject({ type: 'text', text: 'after' })
const tool = parts[1]!
if (tool.type === 'tool') {
expect(tool.state).toBe('complete')
expect(tool.name).toBe('terminal')
expect(tool.resultText).toBe('hello\nworld') // envelope stripped
expect(tool.lineCount).toBe(2)
} else {
throw new Error('expected a tool part at index 1')
}
})
test('tool.complete updates the running tool part IN PLACE (not a new row)', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'x', name: 'read_file' } })
expect(store.state.messages.at(-1)!.parts).toHaveLength(1)
expect(store.state.messages.at(-1)!.parts![0]).toMatchObject({ type: 'tool', state: 'running', name: 'read_file' })
store.apply({ type: 'tool.complete', payload: { tool_id: 'x', summary: 'read 42 lines' } })
const parts = store.state.messages.at(-1)!.parts!
expect(parts).toHaveLength(1) // updated in place — NOT appended as a separate row
const tool = parts[0]!
if (tool.type === 'tool') {
expect(tool.state).toBe('complete')
expect(tool.summary).toBe('read 42 lines')
} else {
throw new Error('expected a tool part')
}
})
test('reasoning.delta accumulates into a reasoning part', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'reasoning.delta', payload: { text: 'thinking ' } })
store.apply({ type: 'reasoning.delta', payload: { text: 'hard' } })
const parts = store.state.messages.at(-1)!.parts ?? []
expect(parts[0]).toMatchObject({ type: 'reasoning', text: 'thinking hard' })
})
})

View file

@ -0,0 +1,43 @@
/**
* toolOutput unit test (spec v4 §5 Layer 4 Hermes-specific contract). The
* `{output,exit_code}` envelope unwrap + the line/char collapse, as pure data.
*/
import { describe, expect, test } from 'bun:test'
import { collapseToolOutput, stripToolEnvelope, truncate } from '../logic/toolOutput.ts'
describe('stripToolEnvelope', () => {
test('unwraps {output,exit_code} → output', () => {
expect(stripToolEnvelope('{"output":"hi","exit_code":0}')).toBe('hi')
})
test('appends an [exit N] suffix on non-zero exit', () => {
expect(stripToolEnvelope('{"output":"oops","exit_code":2}')).toBe('oops\n[exit 2]')
})
test('appends an [error] suffix when error is set', () => {
expect(stripToolEnvelope('{"output":"x","error":"boom"}')).toBe('x\n[error] boom')
})
test('passes through non-JSON / non-envelope unchanged', () => {
expect(stripToolEnvelope('just text')).toBe('just text')
expect(stripToolEnvelope('{not json')).toBe('{not json')
expect(stripToolEnvelope('{"result":"no output key"}')).toBe('{"result":"no output key"}')
})
})
describe('collapseToolOutput / truncate', () => {
test('caps to maxLines and reports the hidden count', () => {
const c = collapseToolOutput('a\nb\nc\nd', 2, 10)
expect(c.lines).toEqual(['a', 'b'])
expect(c.hiddenLines).toBe(2)
expect(c.truncated).toBe(true)
})
test('no truncation when within the cap', () => {
const c = collapseToolOutput('a\nb', 5, 10)
expect(c.lines).toEqual(['a', 'b'])
expect(c.hiddenLines).toBe(0)
expect(c.truncated).toBe(false)
})
test('truncate adds an ellipsis only when cut', () => {
expect(truncate('abcdef', 4)).toBe('abc…')
expect(truncate('ab', 4)).toBe('ab')
})
})

View file

@ -1,34 +1,73 @@
/**
* MessageLine renders one transcript row (spec v4 §2 `view/messageLine.tsx`).
* Phase 2 slice: flat text messages with a role gutter + streaming `` cursor,
* fully themed. The ordered-parts dispatch (text/tool/reasoning §7) replaces the
* flat `text` field in the next slice this file grows into that `<Switch>` loop.
* MessageLine renders one transcript row (spec v4 §2 / §7). An assistant turn
* is ONE ordered `parts[]` dispatched by `<Switch>`/`<Match>` on `part.type`, so
* text / reasoning / tool interleave INLINE (the §7 fix for "tools dump below").
* User/system rows (and settled/resumed assistant rows with no parts) render flat
* `text`. Fully themed; rich text via <b>/<span>, never an attributes bitmask (§8 #1).
*
* Rich text via <b>/<span> children, never an attributes bitmask (gotcha §8 #1);
* inline color is `<span style={{ fg }}>`.
* Stable `id` per part as the <For> key so a new tool part below a streaming text
* part doesn't remount it. Native <markdown> for text parts lands in 2b-ii.
*/
import { Show } from 'solid-js'
import { For, Match, Show, Switch } from 'solid-js'
import type { Message } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
import { ToolPart } from './toolPart.tsx'
const GUTTER = 2
export function MessageLine(props: { message: Message }) {
const theme = useTheme()
const gutter = () =>
props.message.role === 'assistant'
? `${theme().brand.icon} `
: props.message.role === 'user'
? `${theme().brand.prompt} `
: ''
const gutterFg = () => (props.message.role === 'assistant' ? theme().color.accent : theme().color.prompt)
const m = () => props.message
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
const glyphFg = () =>
m().role === 'assistant' ? theme().color.accent : m().role === 'user' ? theme().color.prompt : theme().color.muted
const hasParts = () => (m().parts?.length ?? 0) > 0
return (
<text style={{ flexShrink: 0 }}>
<span style={{ fg: gutterFg() }}>{gutter()}</span>
<span style={{ fg: theme().color.text }}>{props.message.text}</span>
<Show when={props.message.streaming}>
<span style={{ fg: theme().color.muted }}></span>
</Show>
</text>
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: m().role === 'user' ? 1 : 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text>
<span style={{ fg: glyphFg() }}>{glyph()}</span>
</text>
</box>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Show
when={m().role === 'assistant' && hasParts()}
fallback={
<text>
<span style={{ fg: theme().color.text }}>{m().text}</span>
</text>
}
>
<For each={m().parts ?? []}>
{part => (
<Switch>
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
<Match when={part.type === 'reasoning' && part}>
{r => (
<text>
<span style={{ fg: theme().color.muted }}>{r().text}</span>
</text>
)}
</Match>
<Match when={part.type === 'text' && part}>
{t => (
<text>
<span style={{ fg: theme().color.text }}>{t().text}</span>
</text>
)}
</Match>
</Switch>
)}
</For>
</Show>
<Show when={m().streaming && !hasParts()}>
<text>
<span style={{ fg: theme().color.muted }}></span>
</text>
</Show>
</box>
</box>
)
}

View file

@ -0,0 +1,96 @@
/**
* ToolPart compact inline/block render of one tool call (spec v4 §7 / §8;
* ported from the React build's `ToolRow`). Two tiers:
* - running, or complete with 1 output line a one-line row `⚡ name status`
* - complete with multi-line output a left-bar block (NOT a full box), capped
* to TOOL_MAX_LINES with a "… +N more" marker (click-to-expand when mouse is on)
* `resultText` is already `{output,exit_code}`-envelope-stripped by the store.
* Fully themed (no hardcoded styles).
*/
import { type ToolPartState } from '../logic/store.ts'
import { useTerminalDimensions } from '@opentui/solid'
import { createMemo, createSignal, For, Match, Show, Switch } from 'solid-js'
import { collapseToolOutput, truncate } from '../logic/toolOutput.ts'
import { useTheme } from './theme.tsx'
const GUTTER = 2
const TOOL_MAX_LINES = 10
export function ToolPart(props: { part: ToolPartState }) {
const theme = useTheme()
const dims = useTerminalDimensions()
const [expanded, setExpanded] = createSignal(false)
const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4)
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
const lines = () => (result() ? result().split('\n') : [])
const multiline = () => lines().length > 1
const inlineStatus = () => (props.part.error ? `${props.part.error}` : (lines()[0] ?? props.part.summary ?? ''))
const collapsed = createMemo(() =>
collapseToolOutput(result(), expanded() ? lines().length : TOOL_MAX_LINES, bodyWidth() - 2)
)
return (
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: 1 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text>
<span style={{ fg: theme().color.muted }}></span>
</text>
</box>
<Switch>
<Match when={props.part.state === 'running'}>
<text>
<span style={{ fg: theme().color.label }}>{props.part.name}</span>
<span style={{ fg: theme().color.muted }}> </span>
</text>
</Match>
<Match when={!multiline()}>
<text>
<span style={{ fg: theme().color.label }}>{props.part.name}</span>
<Show when={inlineStatus()}>
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
{` ${truncate(inlineStatus(), Math.max(1, bodyWidth() - props.part.name.length - 2))}`}
</span>
</Show>
</text>
</Match>
<Match when={multiline()}>
<box style={{ flexDirection: 'row', flexGrow: 1, minWidth: 0 }} onMouseDown={() => setExpanded(e => !e)}>
<box
style={{
backgroundColor: props.part.error ? theme().color.error : theme().color.border,
flexShrink: 0,
width: 1
}}
/>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, paddingLeft: 1 }}>
<text>
<span style={{ fg: theme().color.label }}>{props.part.name}</span>
</text>
<For each={collapsed().lines}>
{line => (
<text>
<span style={{ fg: theme().color.muted }}>{line}</span>
</text>
)}
</For>
<Show when={collapsed().hiddenLines > 0}>
<text>
<span style={{ fg: theme().color.accent }}>
{`… +${collapsed().hiddenLines} more line${collapsed().hiddenLines === 1 ? '' : 's'}`}
</span>
</text>
</Show>
<Show when={props.part.error}>
<text>
<span style={{ fg: theme().color.error }}>{truncate(props.part.error ?? '', bodyWidth() - 2)}</span>
</text>
</Show>
</box>
</box>
</Match>
</Switch>
</box>
)
}