mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
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:
parent
53b37463c4
commit
b72ac77783
7 changed files with 494 additions and 44 deletions
|
|
@ -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<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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
60
ui-tui-opentui-v2/src/logic/toolOutput.ts
Normal file
60
ui-tui-opentui-v2/src/logic/toolOutput.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
43
ui-tui-opentui-v2/src/test/toolOutput.test.ts
Normal file
43
ui-tui-opentui-v2/src/test/toolOutput.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
96
ui-tui-opentui-v2/src/view/toolPart.tsx
Normal file
96
ui-tui-opentui-v2/src/view/toolPart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue