From b72ac777834d872924dcf7181cd04391d4a6f5e0 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 14:42:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(opentui-v2):=20Phase=202b-i=20=E2=80=94=20?= =?UTF-8?q?ordered=20parts=20+=20inline=20tool=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: + 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 for text parts is the next slice (2b-ii). --- ui-tui-opentui-v2/src/logic/store.ts | 159 +++++++++++++++--- ui-tui-opentui-v2/src/logic/toolOutput.ts | 60 +++++++ ui-tui-opentui-v2/src/test/render.test.tsx | 27 +++ ui-tui-opentui-v2/src/test/store.test.ts | 72 +++++++- ui-tui-opentui-v2/src/test/toolOutput.test.ts | 43 +++++ ui-tui-opentui-v2/src/view/messageLine.tsx | 81 ++++++--- ui-tui-opentui-v2/src/view/toolPart.tsx | 96 +++++++++++ 7 files changed, 494 insertions(+), 44 deletions(-) create mode 100644 ui-tui-opentui-v2/src/logic/toolOutput.ts create mode 100644 ui-tui-opentui-v2/src/test/toolOutput.test.ts create mode 100644 ui-tui-opentui-v2/src/view/toolPart.tsx diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 90f185d4796..5a83b6684e3 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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 start↔complete 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`), skin→theme, 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({ 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() 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. } } diff --git a/ui-tui-opentui-v2/src/logic/toolOutput.ts b/ui-tui-opentui-v2/src/logic/toolOutput.ts new file mode 100644 index 00000000000..c1ec3000898 --- /dev/null +++ b/ui-tui-opentui-v2/src/logic/toolOutput.ts @@ -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 + 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 } +} diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index dbe70cd930c..54a454c856b 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -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( + () => ( + store.state.theme}> + + + ), + { 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 + }) }) diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 93b07720576..9023599f543 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -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 start↔complete 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' }) }) }) diff --git a/ui-tui-opentui-v2/src/test/toolOutput.test.ts b/ui-tui-opentui-v2/src/test/toolOutput.test.ts new file mode 100644 index 00000000000..642358e6c97 --- /dev/null +++ b/ui-tui-opentui-v2/src/test/toolOutput.test.ts @@ -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') + }) +}) diff --git a/ui-tui-opentui-v2/src/view/messageLine.tsx b/ui-tui-opentui-v2/src/view/messageLine.tsx index 4550305f650..f96d0d8c047 100644 --- a/ui-tui-opentui-v2/src/view/messageLine.tsx +++ b/ui-tui-opentui-v2/src/view/messageLine.tsx @@ -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 `` loop. + * MessageLine — renders one transcript row (spec v4 §2 / §7). An assistant turn + * is ONE ordered `parts[]` dispatched by ``/`` 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 /, never an attributes bitmask (§8 #1). * - * Rich text via / children, never an attributes bitmask (gotcha §8 #1); - * inline color is ``. + * Stable `id` per part as the key so a new tool part below a streaming text + * part doesn't remount it. Native 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 ( - - {gutter()} - {props.message.text} - - - - + + + + {glyph()} + + + + + {m().text} + + } + > + + {part => ( + + {tool => } + + {r => ( + + {r().text} + + )} + + + {t => ( + + {t().text} + + )} + + + )} + + + + + + + + + ) } diff --git a/ui-tui-opentui-v2/src/view/toolPart.tsx b/ui-tui-opentui-v2/src/view/toolPart.tsx new file mode 100644 index 00000000000..1449ef83593 --- /dev/null +++ b/ui-tui-opentui-v2/src/view/toolPart.tsx @@ -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 ( + + + + + + + + + + {props.part.name} + + + + + + {props.part.name} + + + {` ${truncate(inlineStatus(), Math.max(1, bodyWidth() - props.part.name.length - 2))}`} + + + + + + setExpanded(e => !e)}> + + + + {props.part.name} + + + {line => ( + + {line} + + )} + + 0}> + + + {`… +${collapsed().hiddenLines} more line${collapsed().hiddenLines === 1 ? '' : 's'}`} + + + + + + {truncate(props.part.error ?? '', bodyWidth() - 2)} + + + + + + + + ) +}