feat(opentui-v2): Phase 2a — scrollbox transcript + textarea composer + header

Turns the read-only Phase-1 view into an interactive shell, split into focused
view components (spec v4 §2 layout):

- view/transcript.tsx: ONE full-height <scrollbox> with a reactive <For>
  (opencode's no-scrollback model). Applies the §8 #2 gotchas exactly:
  minHeight:0 on the wrapper AND the scrollbox, NO flexDirection on the
  scrollbox root, stickyScroll + stickyStart="bottom".
- view/composer.tsx: a native <textarea> captured by ref — flexShrink:0,
  focus-on-mount, Enter->submit via keyBindings, imperative .clear() on submit,
  and a `submitting` re-entrancy guard. Wired by the entry to fire prompt.submit
  (Effect.runFork on the in-hand service value); it's now the PRIMARY input, with
  the HERMES_TUI_PROMPT stand-in kept only for launch-with-prompt.
- view/header.tsx + view/messageLine.tsx: extracted, themed (no hardcoded
  styles). MessageLine stays flat-text this slice; ordered parts (§7) land in 2b.

test/lib/render.ts now flushes 3 renderOnce passes before capture — a <scrollbox>
needs more than one pass to measure content + apply sticky, else the transcript
row paints blank.

Verified: bun run check green (12 tests / 4 files / 31 expects). Live tmux drive:
typed into the composer -> cleared -> user row -> streamed reply ("Here are three
words"); Ctrl+C quits cleanly even with the textarea focused, no orphan child.
Composer placeholder rendered the live skin's welcome string (skin->theme live).
Smoke P2a + parity matrix updated. Phase 2b (ordered parts/tool render/markdown)
is the next slice.
This commit is contained in:
alt-glitch 2026-06-08 14:28:59 +00:00
parent 927c902785
commit 4b81ded58b
7 changed files with 177 additions and 37 deletions

View file

@ -92,6 +92,25 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
const gateway = yield* GatewayService
yield* gateway.subscribe(event => store.apply(event))
// Composer submit: a plain callback the Solid view calls. The service value
// is already in hand, so `gateway.request(...)` is Effect<…, never> — fire it
// detached with runFork; failures are logged, never thrown into the view.
const submit = (text: string) => {
store.pushUser(text)
const sid = gateway.sessionId()
if (!sid) {
getLog().warn('submit', 'no session yet — dropping prompt', { text })
return
}
Effect.runFork(
gateway
.request('prompt.submit', { session_id: sid, text })
.pipe(
Effect.catchCause(cause => Effect.sync(() => getLog().warn('submit', 'failed', { cause: String(cause) })))
)
)
}
// Live backend: drive a session (create + optional initial prompt) concurrently.
if (!input.fake) yield* Effect.forkScoped(bootstrapSession(gateway, store, input))
@ -101,7 +120,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
render(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
<App store={store} onSubmit={submit} />
</ThemeProvider>
),
renderer

View file

@ -29,12 +29,13 @@ describe('App render (Phase 1, themed)', () => {
<App store={store} />
</ThemeProvider>
),
{ width: 60, height: 8 }
{ width: 60, height: 16 }
)
expect(frame).toContain('Hermes Agent') // default brand.name
expect(frame).toContain('ready')
expect(frame).toContain('Hi there, glitch!')
expect(frame).toContain('Type your message') // composer placeholder (brand.welcome)
})
test('applying a skin re-themes the brand name (skinnable, no hardcoding)', async () => {
@ -48,7 +49,7 @@ describe('App render (Phase 1, themed)', () => {
<App store={store} />
</ThemeProvider>
),
{ width: 60, height: 8 }
{ width: 60, height: 16 }
)
expect(frame).toContain('Zephyr')

View file

@ -1,49 +1,33 @@
/**
* App the Solid view shell (spec v4 §2 `view/App.tsx`). Phase 1: header +
* transcript, fully themed via `useTheme()` NO hardcoded styles (spec §7.5).
* The streamed message + skin both come from the store; the boundary feeds them.
* App the Solid view shell (spec v4 §2 `view/App.tsx`). Phase 2: header +
* scrolling transcript + composer, composed in a flex column. Fully themed via
* the ThemeProvider NO hardcoded styles (§7.5).
*
* Rich text uses <b>/<span> children, never an attributes bitmask (gotcha §8 #1).
* Inline color goes in `style={{ fg }}` on <span>; <text> accepts `fg` directly.
* header flexShrink:0 (top chrome line)
* transcript flexGrow:1, minHeight:0 (the one <scrollbox>; §8 #2 gotchas)
* composer flexShrink:0 (the <textarea>; clears on submit, §8 #3)
*
* `onSubmit` is wired by the entry (Effect boundary) to fire `prompt.submit`;
* it's optional so headless frame tests can mount the shell without a gateway.
*/
import { For, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
import { Composer } from './composer.tsx'
import { Header } from './header.tsx'
import { Transcript } from './transcript.tsx'
export interface AppProps {
readonly store: SessionStore
readonly onSubmit?: (text: string) => void
}
export function App(props: AppProps) {
const theme = useTheme()
const NOOP = () => {}
export function App(props: AppProps) {
return (
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
<box style={{ flexShrink: 0 }}>
<text>
<b>{theme().brand.name}</b>
<span style={{ fg: theme().color.muted }}> · opentui · </span>
<Show when={props.store.state.ready} fallback={<span style={{ fg: theme().color.muted }}>connecting</span>}>
<span style={{ fg: theme().color.ok }}>ready</span>
</Show>
</text>
</box>
<box style={{ flexDirection: 'column', flexGrow: 1, minHeight: 0, marginTop: 1 }}>
<For each={props.store.state.messages}>
{message => (
<text>
<span style={{ fg: message.role === 'assistant' ? theme().color.accent : theme().color.prompt }}>
{message.role === 'assistant' ? `${theme().brand.icon} ` : `${theme().brand.prompt} `}
</span>
<span style={{ fg: theme().color.text }}>{message.text}</span>
<Show when={message.streaming}>
<span style={{ fg: theme().color.muted }}></span>
</Show>
</text>
)}
</For>
</box>
<Header store={props.store} />
<Transcript store={props.store} />
<Composer onSubmit={props.onSubmit ?? NOOP} />
</box>
)
}

View file

@ -0,0 +1,50 @@
/**
* Composer the input row (spec v4 §2 `view/composer.tsx`). Phase 2: a native
* <textarea> captured by ref; Enter submits, the input clears imperatively.
*
* Gotchas (§8 #3): `flexShrink:0` on the wrapper so it never collapses onto its
* rule under a full transcript; clear via the renderable's `.clear()` (NOT a
* `key`-remount or controlled `value=""`); a `submitting` re-entrancy guard so a
* double-Enter can't read the now-empty buffer and submit a phantom prompt.
*
* `onSubmit` is a plain callback wired by the entry (Effect boundary) it fires
* the `prompt.submit` RPC. The composer itself stays pure Solid (no Effect).
*/
import { type TextareaRenderable } from '@opentui/core'
import { onMount } from 'solid-js'
import { useTheme } from './theme.tsx'
export function Composer(props: { onSubmit: (text: string) => void }) {
const theme = useTheme()
let ta: TextareaRenderable | undefined
let submitting = false
const submit = () => {
if (submitting || !ta) return
const text = ta.plainText.trim()
if (!text) return
submitting = true
props.onSubmit(text)
ta.clear()
submitting = false
}
onMount(() => ta?.focus())
return (
<box style={{ flexShrink: 0, marginTop: 1 }}>
<textarea
ref={el => (ta = el)}
style={{ height: 3, width: '100%' }}
placeholder={theme().brand.welcome}
placeholderColor={theme().color.muted}
textColor={theme().color.text}
cursorColor={theme().color.accent}
focusedBackgroundColor={theme().color.statusBg}
keyBindings={[{ action: 'submit', name: 'return' }]}
onSubmit={submit}
/>
</box>
)
}

View file

@ -0,0 +1,25 @@
/**
* Header the top chrome line (spec v4 §2 `view/header.tsx`). Phase 2 skeleton:
* brand · engine · ready/connecting, fully themed (`useTheme()`, NO hardcoded
* styles §7.5). Model / cwd / context% / cost land in Phase 5b once
* `session.info` + `Usage` are wired.
*/
import { Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
export function Header(props: { store: SessionStore }) {
const theme = useTheme()
return (
<box style={{ flexShrink: 0 }}>
<text>
<b>{theme().brand.name}</b>
<span style={{ fg: theme().color.muted }}> · opentui · </span>
<Show when={props.store.state.ready} fallback={<span style={{ fg: theme().color.muted }}>connecting</span>}>
<span style={{ fg: theme().color.ok }}>ready</span>
</Show>
</text>
</box>
)
}

View file

@ -0,0 +1,34 @@
/**
* 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.
*
* Rich text via <b>/<span> children, never an attributes bitmask (gotcha §8 #1);
* inline color is `<span style={{ fg }}>`.
*/
import { Show } from 'solid-js'
import type { Message } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
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)
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>
)
}

View file

@ -0,0 +1,27 @@
/**
* Transcript the scrolling message pane (spec v4 §2 `view/transcript.tsx`).
*
* ONE full-height <scrollbox> with a reactive <For> (opencode's model the
* viewport clips growing output so terminal scrollback is never corrupted; no
* `writeToScrollback`). Carries the §8 #2 gotchas EXACTLY:
* - `minHeight:0` on BOTH the wrapper box AND the <scrollbox> (so the flex
* child can shrink below content height instead of pushing the composer off),
* - NO `flexDirection` on the <scrollbox> ROOT style (it has internal
* viewport/content children; setting it there breaks content-height
* measurement phantom scroll offset that clips the top + leaves a gap),
* - `stickyScroll` + `stickyStart="bottom"` to pin the latest line.
*/
import { For } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { MessageLine } from './messageLine.tsx'
export function Transcript(props: { store: SessionStore }) {
return (
<box style={{ flexGrow: 1, minHeight: 0, marginTop: 1 }}>
<scrollbox style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
</scrollbox>
</box>
)
}