diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx
index 5afbda28013..98bda9de7d1 100644
--- a/ui-tui-opentui-v2/src/entry/main.tsx
+++ b/ui-tui-opentui-v2/src/entry/main.tsx
@@ -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(
() => (
store.state.theme}>
-
+
),
renderer
diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx
index c8119da5a3e..dbe70cd930c 100644
--- a/ui-tui-opentui-v2/src/test/render.test.tsx
+++ b/ui-tui-opentui-v2/src/test/render.test.tsx
@@ -29,12 +29,13 @@ describe('App render (Phase 1, themed)', () => {
),
- { 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)', () => {
),
- { width: 60, height: 8 }
+ { width: 60, height: 16 }
)
expect(frame).toContain('Zephyr')
diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx
index cec5419ebcb..d79dbbb2f25 100644
--- a/ui-tui-opentui-v2/src/view/App.tsx
+++ b/ui-tui-opentui-v2/src/view/App.tsx
@@ -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 / children, never an attributes bitmask (gotcha §8 #1).
- * Inline color goes in `style={{ fg }}` on ; accepts `fg` directly.
+ * header flexShrink:0 (top chrome line)
+ * transcript flexGrow:1, minHeight:0 (the one ; §8 #2 gotchas)
+ * composer flexShrink:0 (the }>
- ready
-
-
-
-
-
- {message => (
-
-
- {message.role === 'assistant' ? `${theme().brand.icon} ` : `${theme().brand.prompt} `}
-
- {message.text}
-
- ▍
-
-
- )}
-
-
+
+
+
)
}
diff --git a/ui-tui-opentui-v2/src/view/composer.tsx b/ui-tui-opentui-v2/src/view/composer.tsx
new file mode 100644
index 00000000000..15901f27488
--- /dev/null
+++ b/ui-tui-opentui-v2/src/view/composer.tsx
@@ -0,0 +1,50 @@
+/**
+ * Composer — the input row (spec v4 §2 `view/composer.tsx`). Phase 2: a native
+ * }>
+ ready
+
+
+
+ )
+}
diff --git a/ui-tui-opentui-v2/src/view/messageLine.tsx b/ui-tui-opentui-v2/src/view/messageLine.tsx
new file mode 100644
index 00000000000..4550305f650
--- /dev/null
+++ b/ui-tui-opentui-v2/src/view/messageLine.tsx
@@ -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 `` loop.
+ *
+ * Rich text via / children, never an attributes bitmask (gotcha §8 #1);
+ * inline color is ``.
+ */
+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 (
+
+ {gutter()}
+ {props.message.text}
+
+ ▍
+
+
+ )
+}
diff --git a/ui-tui-opentui-v2/src/view/transcript.tsx b/ui-tui-opentui-v2/src/view/transcript.tsx
new file mode 100644
index 00000000000..d8d3380e3f2
--- /dev/null
+++ b/ui-tui-opentui-v2/src/view/transcript.tsx
@@ -0,0 +1,27 @@
+/**
+ * Transcript — the scrolling message pane (spec v4 §2 `view/transcript.tsx`).
+ *
+ * ONE full-height with a reactive (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 (so the flex
+ * child can shrink below content height instead of pushing the composer off),
+ * - NO `flexDirection` on the 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 (
+
+
+ {message => }
+
+
+ )
+}