mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
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:
parent
927c902785
commit
4b81ded58b
7 changed files with 177 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
50
ui-tui-opentui-v2/src/view/composer.tsx
Normal file
50
ui-tui-opentui-v2/src/view/composer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
ui-tui-opentui-v2/src/view/header.tsx
Normal file
25
ui-tui-opentui-v2/src/view/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
ui-tui-opentui-v2/src/view/messageLine.tsx
Normal file
34
ui-tui-opentui-v2/src/view/messageLine.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
ui-tui-opentui-v2/src/view/transcript.tsx
Normal file
27
ui-tui-opentui-v2/src/view/transcript.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue