feat(tui): run on Node 26 (one runtime), finalize copy UX, rename to ui-opentui

Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the
repo ships a single JavaScript runtime: child_process for the gateway, vitest
for tests, an esbuild + Solid build step. Mouse selection copies the rendered
text you highlight, and the clipboard path is crash-proofed (a broken copy
pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ ->
ui-opentui/ and updates the launcher/installer/Docker references.
This commit is contained in:
alt-glitch
2026-06-09 16:16:48 +00:00
parent 25567919ea
commit ae11a636dc
85 changed files with 5613 additions and 900 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* App — the Solid view shell (spec v4 §2 `view/App.tsx`). Header + a content zone
* that is either the PAGER overlay (long slash output) or the normal
* transcript + input zone; the input zone is one of: blocking prompt, session
* switcher, generic picker (model/skills), or the composer. Fully themed (§7.5).
*
* header flexShrink:0 (top chrome line)
* content flexGrow:1, minHeight:0 — Pager OR (transcript + input zone)
* transcript flexGrow:1, minHeight:0 (the one <scrollbox>; §8 #2 gotchas)
* input zone flexShrink:0 (PromptOverlay | SessionSwitcher | Picker | Composer)
*
* Overlays REPLACE rather than stack (a `<Switch>`), so the composer remounts +
* refocuses when an overlay closes; the key that closed an overlay can't leak
* into it because the close is deferred a tick.
*/
import { Match, Switch } from 'solid-js'
import { deferClose } from '../logic/defer.ts'
import type { PromptHistory } from '../logic/history.ts'
import type { PasteStore } from '../logic/pastes.ts'
import type { SessionStore } from '../logic/store.ts'
import { Composer } from './composer.tsx'
import { DimensionsProvider } from './dimensions.tsx'
import { Header } from './header.tsx'
import { AgentsDashboard } from './overlays/agentsDashboard.tsx'
import { Pager } from './overlays/pager.tsx'
import { Picker } from './overlays/picker.tsx'
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
import { PromptOverlay } from './prompts/promptOverlay.tsx'
import { StatusBar } from './statusBar.tsx'
import { StatusLine } from './statusLine.tsx'
import { useTheme } from './theme.tsx'
import { Transcript } from './transcript.tsx'
export interface AppProps {
readonly store: SessionStore
readonly onSubmit?: (text: string) => void
readonly onType?: (text: string) => void
readonly onRespond?: (method: string, params: Record<string, unknown>) => void
readonly onResume?: (sessionId: string) => void
readonly sessionId?: () => string | undefined
readonly history?: PromptHistory
readonly onImagePaste?: () => void
readonly pasteStore?: PasteStore
}
const NOOP = () => {}
const NOOP_RESPOND = () => {}
const NOOP_RESUME = () => {}
const NO_SESSION = () => undefined
export function App(props: AppProps) {
const theme = useTheme()
const blocked = () => props.store.state.prompt !== undefined
const pager = () => props.store.state.pager
const dashboard = () => props.store.state.dashboard
const switcher = () => props.store.state.switcher
const picker = () => props.store.state.picker
// Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in
// the freshly-remounted composer (see deferClose).
const closePager = () => deferClose(() => props.store.closePager())
const closeDashboard = () => deferClose(() => props.store.closeDashboard())
const closeSwitcher = () => deferClose(() => props.store.closeSwitcher())
const closePicker = () => deferClose(() => props.store.closePicker())
const resume = (id: string) => {
;(props.onResume ?? NOOP_RESUME)(id)
closeSwitcher()
}
return (
<DimensionsProvider>
<box style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1, paddingLeft: 1, paddingRight: 1 }}>
{/* a bottom rule under the header bookends the transcript with the status
bar's top rule — frames the chrome as intentional (item 8). */}
<box border={['bottom']} borderColor={theme().color.border} style={{ flexShrink: 0 }}>
<Header store={props.store} />
</box>
{/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */}
<Switch
fallback={
<>
<Transcript store={props.store} />
{/* transient busy face floats at the bottom of the transcript area */}
<StatusLine store={props.store} />
{/* input region — a top-edge rule separates the status bar + textbox from the
transcript above; the status bar sits directly ABOVE the composer (item 14). */}
<box
border={['top']}
borderColor={theme().color.border}
style={{ flexShrink: 0, flexDirection: 'column' }}
>
<StatusBar store={props.store} />
<Switch
fallback={
<Composer
onSubmit={props.onSubmit ?? NOOP}
onType={props.onType}
completions={() => props.store.state.completions ?? []}
completionFrom={() => props.store.state.completionFrom}
onDismiss={() => props.store.clearCompletions()}
history={props.history}
onImagePaste={props.onImagePaste}
pasteStore={props.pasteStore}
/>
}
>
<Match when={blocked()}>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Match>
<Match when={switcher()}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Match>
<Match when={picker()}>
{p => (
<Picker
title={p().title}
items={p().items}
onPick={value => {
p().onPick(value)
closePicker()
}}
onClose={closePicker}
/>
)}
</Match>
</Switch>
</box>
</>
}
>
<Match when={pager()}>{p => <Pager title={p().title} text={p().text} onClose={closePager} />}</Match>
<Match when={dashboard()}>
<AgentsDashboard subagents={props.store.state.subagents} onClose={closeDashboard} />
</Match>
</Switch>
</box>
</DimensionsProvider>
)
}
+240
View File
@@ -0,0 +1,240 @@
/**
* Composer — the input row (spec v4 §2). A native <textarea> captured by ref;
* Enter submits, the input clears imperatively, and a live slash-completion
* dropdown renders ABOVE it as you type `/…` (spec §1 completions).
*
* Gotchas (§8 #3): `flexShrink:0` so it never collapses onto its rule; clear via
* `.clear()` (NOT key-remount); a `submitting` re-entrancy guard.
*
* Completions: `onContentChange` reports the text → `onType` (entry boundary)
* queries `complete.slash` and fills `completions()`. The textarea owns key input
* (so live-refine-by-typing works), so we use Tab to accept the top match and Esc
* to dismiss (arrow-nav would fight the textarea's cursor; a polish item).
* `onSubmit`/`onType` are plain callbacks wired by the entry — no Effect here.
*
* Always-active input (item 2): the textarea focuses on mount, on click
* (onMouseDown), and reclaims focus on the next PRINTABLE keystroke if focus ever
* drifted off (e.g. the transcript scrollbox grabbed it on a mouse-scroll). Nav
* keys are left alone so keyboard transcript-scroll still works (opencode keeps
* the prompt focused via a reactive effect; here a keystroke net is enough since
* the composer remounts+refocuses whenever an overlay closes).
*/
import { type PasteEvent, type TextareaRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { For, onMount, Show } from 'solid-js'
import type { CompletionItem } from '../logic/store.ts'
import type { PromptHistory } from '../logic/history.ts'
import { type PasteStore, shouldPlaceholder } from '../logic/pastes.ts'
import { useDimensions } from './dimensions.tsx'
import { useTheme } from './theme.tsx'
const GUTTER = 2
/** Keys that must NOT steal focus back to the composer (scroll/edit/nav). */
const NAV_KEYS = new Set([
'return',
'linefeed',
'tab',
'escape',
'backspace',
'delete',
'insert',
'up',
'down',
'left',
'right',
'home',
'end',
'pageup',
'pagedown',
'clear',
'menu'
])
/** A printable, unmodified key press (recoverable into the textarea). */
function isPrintableKey(k: {
name: string
ctrl: boolean
meta: boolean
option: boolean
super?: boolean
sequence: string
eventType?: string
}): boolean {
return (
k.eventType !== 'release' &&
!k.ctrl &&
!k.meta &&
!k.option &&
!k.super &&
!NAV_KEYS.has(k.name) &&
typeof k.sequence === 'string' &&
k.sequence.length >= 1 &&
(k.sequence.codePointAt(0) ?? 0) >= 0x20
)
}
export function Composer(props: {
onSubmit: (text: string) => void
onType?: ((text: string) => void) | undefined
completions?: (() => CompletionItem[]) | undefined
completionFrom?: (() => number) | undefined
onDismiss?: (() => void) | undefined
history?: PromptHistory | undefined
onImagePaste?: (() => void) | undefined
pasteStore?: PasteStore | undefined
}) {
const theme = useTheme()
const dims = useDimensions()
// Auto-expand the input up to ~a third of the screen, then it scrolls internally
// (opencode's prompt: minHeight 1, maxHeight max(6, ⌊rows/3⌋)).
const maxHeight = () => Math.max(6, Math.floor(dims().height / 3))
let ta: TextareaRenderable | undefined
let submitting = false
const completions = () => props.completions?.() ?? []
/** Replace the textarea content and park the cursor at the end (history recall). */
const setBuffer = (text: string) => {
if (!ta) return
ta.setText(text)
ta.cursorOffset = text.length
}
const submit = () => {
if (submitting || !ta) return
// Expand any `[Pasted text #N]` placeholders back to their full content before
// sending (item: pasted-text). No-op when nothing was placeheld.
const text = (props.pasteStore?.expand(ta.plainText) ?? ta.plainText).trim()
if (!text) return
submitting = true
props.onSubmit(text)
props.history?.push(text)
ta.clear()
props.pasteStore?.clear()
props.onDismiss?.()
submitting = false
}
useKeyboard(key => {
// 1) completion accept (Tab) / dismiss (Esc) while the dropdown is open
if (completions().length > 0) {
if (key.name === 'tab') {
const top = completions()[0]
if (top && ta) {
// splice only the token being completed (slash-arg / @-mention), not the
// whole line — `completionFrom` is the gateway's replace_from / token start.
const from = props.completionFrom?.() ?? 0
const before = ta.plainText.slice(0, Math.min(Math.max(0, from), ta.plainText.length))
setBuffer(before + top.text + ' ')
props.onDismiss?.()
}
return
}
if (key.name === 'escape') {
props.onDismiss?.()
return
}
}
// 2) prompt history (item 6): Up at the first line → older prompt; Down at the
// last line → newer/draft. At the boundary the textarea's own up/down is a
// no-op, so there's no conflict; mid-buffer it falls through to cursor moves.
if (ta && props.history) {
if (key.name === 'up' && ta.logicalCursor.row === 0) {
const entry = props.history.prev(ta.plainText)
if (entry !== null) setBuffer(entry)
return
}
if (key.name === 'down' && ta.logicalCursor.row === ta.lineCount - 1) {
const entry = props.history.next()
if (entry !== null) setBuffer(entry)
return
}
// any edit resets the recall cursor so the next Up starts from the bottom
if (key.name === 'backspace' || key.name === 'delete' || isPrintableKey(key)) {
props.history.reset()
}
}
// 3) always-active input (item 2): a printable key while the textarea lost
// focus reclaims it. The renderer runs this GLOBAL handler BEFORE routing the
// key to the focused renderable, so after focus() the SAME keystroke is still
// delivered to the (now-focused) textarea — do NOT insert it here too, or the
// first letter doubles. Nav/scroll keys are untouched.
if (ta && !ta.focused && isPrintableKey(key)) {
ta.focus()
}
})
onMount(() => ta?.focus())
return (
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<Show when={completions().length > 0}>
<box
style={{
backgroundColor: theme().color.completionBg,
flexDirection: 'column',
paddingLeft: 1,
paddingRight: 1
}}
>
{/* the completion dropdown is transient input chrome (menu rows + the
key-hint) — not transcript content — so it's excluded from mouse
selection (item 4). */}
<For each={completions().slice(0, 8)}>
{(c, i) => (
<text selectable={false} fg={i() === 0 ? theme().color.accent : theme().color.text}>
{c.display || c.text}
{c.meta ? ` ${c.meta}` : ''}
</text>
)}
</For>
<text selectable={false} fg={theme().color.muted}>
Tab complete · Esc dismiss
</text>
</box>
</Show>
{/* prompt glyph + textarea — the glyph (item 3) marks the input line so the
composer is distinguished by structure (glyph + the status-bar rule above),
not a background tint. */}
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: theme().color.prompt }}>{theme().brand.prompt}</span>
</text>
</box>
<textarea
ref={el => (ta = el)}
minHeight={1}
maxHeight={maxHeight()}
style={{ flexGrow: 1, minWidth: 0 }}
placeholder={theme().brand.welcome}
placeholderColor={theme().color.muted}
textColor={theme().color.text}
cursorColor={theme().color.accent}
keyBindings={[{ action: 'submit', name: 'return' }]}
onMouseDown={() => ta?.focus()}
onSubmit={submit}
onPaste={(e: PasteEvent) => {
const text = new TextDecoder().decode(e.bytes)
// An empty bracketed paste = an image-only clipboard (item 1) — read + attach it.
if (text.trim() === '') {
e.preventDefault()
props.onImagePaste?.()
return
}
// A large paste becomes a compact `[Pasted text #N +M lines]` chip instead
// of flooding the input; the real text is expanded back on submit.
if (props.pasteStore && shouldPlaceholder(text)) {
e.preventDefault()
ta?.insertText(props.pasteStore.add(text))
return
}
// small pastes fall through to the textarea's native insert
}}
onContentChange={() => props.onType?.(ta?.plainText ?? '')}
/>
</box>
</box>
)
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Shared, COALESCED terminal dimensions (item 4 — resize hardening). Raw
* `useTerminalDimensions()` fires on every SIGWINCH tick; during a drag that's a
* recompute/reflow storm across every width-sensitive component (tool bodies,
* tables, status bar, banner). One provider runs the raw hook once and feeds a
* single leading+trailing-debounced signal (opencode's createLeadingTrailingSignal
* idiom, mirroring the gateway's 16ms event coalescing) that every consumer shares
* — so they reflow together (no tearing) and at most once per COALESCE window.
*/
import { useTerminalDimensions } from '@opentui/solid'
import { type Accessor, createContext, createEffect, createSignal, type JSX, onCleanup, useContext } from 'solid-js'
export interface Dims {
readonly width: number
readonly height: number
}
const DimsContext = createContext<Accessor<Dims>>()
const COALESCE_MS = 40
export function DimensionsProvider(props: { children: JSX.Element }) {
const raw = useTerminalDimensions()
const [dims, setDims] = createSignal<Dims>({ height: raw().height, width: raw().width })
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
createEffect(() => {
const next: Dims = { height: raw().height, width: raw().width } // track raw
const now = Date.now()
if (now - last >= COALESCE_MS) {
last = now
setDims(next) // leading edge: respond immediately to the first change
} else {
// trailing edge: coalesce the burst, land on the final size once it settles
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
last = Date.now()
setDims(next)
}, COALESCE_MS)
}
})
onCleanup(() => {
if (timer) clearTimeout(timer)
})
return <DimsContext.Provider value={dims}>{props.children}</DimsContext.Provider>
}
/** Coalesced dimensions; falls back to the raw hook outside a provider (e.g. headless tests). */
export function useDimensions(): Accessor<Dims> {
return useContext(DimsContext) ?? useTerminalDimensions()
}
+30
View File
@@ -0,0 +1,30 @@
/**
* 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 selectable={false}>
{/* brand glyph in accent + name in primary/bold so the header reads as the
top of the hierarchy, not just another text line (item 8). */}
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.name}</b>
</span>
<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>
)
}
+203
View File
@@ -0,0 +1,203 @@
/**
* HomeHint — the empty-transcript home screen (items 12 + 9; Ink `branding.tsx`
* parity). The HERMES-AGENT banner + a tagline, then a session info block
* (model · Nous Research / dir / Session id), then SEPARATE collapsible sections —
* Available Tools (enabled toolsets + their tools), Available Skills, MCP Servers —
* and a summary line. Fully themed; decorative, so `selectable={false}` (item 4).
*/
import { createSignal, For, type JSX, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { truncate } from '../logic/toolOutput.ts'
import { useDimensions } from './dimensions.tsx'
import { useTheme } from './theme.tsx'
// The canonical HERMES-AGENT block logo (hermes_cli/banner.py), gold→amber→bronze.
const BANNER: ReadonlyArray<readonly [string, 'primary' | 'accent' | 'border']> = [
['██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', 'primary'],
['██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', 'primary'],
['███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║', 'accent'],
['██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║', 'accent'],
['██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║', 'border'],
['╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝', 'border']
]
const BANNER_W = 102
const TOOLSETS_MAX = 10
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`. */
const shortModel = (m: string) => (m.includes('/') ? (m.split('/').at(-1) ?? m) : m)
const HOME = process.env.HOME ?? ''
const shortCwd = (cwd: string) => (HOME && cwd.startsWith(HOME) ? '~' + cwd.slice(HOME.length) : cwd)
export function HomeHint(props: { store: SessionStore }) {
const theme = useTheme()
const dims = useDimensions()
const wide = () => dims().width >= BANNER_W
const cat = () => props.store.state.catalog
const info = () => props.store.state.info
const enabledToolsets = () => (cat()?.tools.toolsets ?? []).filter(t => t.enabled)
// A collapsible section: ▸/▾ accent chevron + label title + optional muted suffix.
function Section(p: { title: string; suffix?: string; open?: boolean; children: JSX.Element }) {
const [open, setOpen] = createSignal(p.open ?? false)
return (
<box style={{ flexDirection: 'column', marginTop: 1 }}>
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => setOpen(o => !o)}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{open() ? '▾ ' : '▸ '}</span>
<span style={{ fg: theme().color.label }}>{p.title}</span>
<Show when={p.suffix}>
<span style={{ fg: theme().color.muted }}>{` ${p.suffix}`}</span>
</Show>
</text>
</box>
<Show when={open()}>
<box
style={{ flexDirection: 'column', marginLeft: 2, paddingLeft: 1 }}
border={['left']}
borderColor={theme().color.border}
>
{p.children}
</box>
</Show>
</box>
)
}
return (
<box style={{ flexDirection: 'column', flexShrink: 0, paddingLeft: 1, marginTop: 1 }}>
{/* banner — full block logo when there's room, else a compact brand line */}
<Show
when={wide()}
fallback={
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{theme().brand.icon} </span>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.name}</b>
</span>
</text>
}
>
<For each={BANNER}>
{([line, tone]) => (
<text selectable={false}>
<span style={{ fg: theme().color[tone] }}>{line}</span>
</text>
)}
</For>
</Show>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
<span style={{ fg: theme().color.muted }}>Nous Research · Messenger of the Digital Gods</span>
</text>
{/* framed session panel (Ink SessionPanel parity) — the bordered box is the
key "this is a designed home screen, not log output" signal. */}
<box
style={{ flexDirection: 'column', marginTop: 1, paddingLeft: 1, paddingRight: 1 }}
border
borderColor={theme().color.border}
>
{/* session info block: model · Nous Research / dir / Session id */}
<box style={{ flexDirection: 'column' }}>
<Show when={info().model}>
{model => (
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{shortModel(model())}</span>
<span style={{ fg: theme().color.muted }}> · Nous Research</span>
</text>
)}
</Show>
<Show when={info().cwd}>
{cwd => (
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{shortCwd(cwd())}</span>
<Show when={info().branch}>
<span style={{ fg: theme().color.muted }}>{` (${info().branch})`}</span>
</Show>
</text>
)}
</Show>
<Show when={props.store.state.sessionId}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>Session: </span>
<span style={{ fg: theme().color.border }}>{props.store.state.sessionId}</span>
</text>
</Show>
</box>
{/* SEPARATE collapsible sections (Ink parity) + summary */}
<Show when={cat()}>
{c => (
<box style={{ flexDirection: 'column' }}>
<Section title="Available Tools" open>
<For each={enabledToolsets().slice(0, TOOLSETS_MAX)}>
{ts => (
<text selectable={false}>
<span style={{ fg: theme().color.label }}>{`${ts.name}: `}</span>
<span style={{ fg: theme().color.muted }}>
{truncate(
ts.tools.join(', ') || `${ts.count} tools`,
Math.max(20, dims().width - ts.name.length - 8)
)}
</span>
</text>
)}
</For>
<Show when={enabledToolsets().length > TOOLSETS_MAX}>
<text selectable={false}>
<span
style={{ fg: theme().color.muted }}
>{`(and ${enabledToolsets().length - TOOLSETS_MAX} more toolsets…)`}</span>
</text>
</Show>
</Section>
<Section
title={`Available Skills (${c().skills.total})`}
suffix={`in ${c().skills.categories.length} categories`}
>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>
{c()
.skills.categories.map(s => `${s.name} (${s.count})`)
.join(' ')}
</span>
</text>
</Section>
<Section
title={`MCP Servers (${c().mcp.servers.length})`}
suffix={c().mcp.servers.length ? 'connected' : ''}
>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{c().mcp.servers.join(' ') || 'none configured'}</span>
</text>
</Section>
<box style={{ marginTop: 1 }}>
<text selectable={false}>
<span style={{ fg: theme().color.text }}>{`${c().tools.total} tools`}</span>
<span
style={{ fg: theme().color.muted }}
>{` · ${c().skills.total} skills · ${c().mcp.servers.length} MCP · `}</span>
<span style={{ fg: theme().color.accent }}>/help</span>
<span style={{ fg: theme().color.muted }}> for commands</span>
</text>
</box>
</box>
)}
</Show>
</box>
{/* end framed session panel */}
<box style={{ marginTop: 1 }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>
Type to chat · history · @file to mention · Ctrl+C to stop/quit
</span>
</text>
</box>
</box>
)
}
+34
View File
@@ -0,0 +1,34 @@
/**
* keymap.tsx — thin Solid helpers over the native `@opentui/keymap` (Phase 3).
*
* `useCloseLayer` is the shared CLOSE binding for overlays/prompts: a `close`
* command bound to Esc and Ctrl+C, scoped to the overlay's root box via a
* `focus-within` layer (the default when a `target` accessor is present). The
* box itself isn't focused — the native `<select>`/`<textarea>` inside it is —
* so `focus-within` is what makes the layer active while the overlay owns the
* screen. The keymap host is provided once at the entry by `<KeymapProvider>`.
*/
import type { BoxRenderable } from '@opentui/core'
import { useBindings } from '@opentui/keymap/solid'
/**
* Bind Esc / Ctrl+C → `onClose`, scoped to the given root box (focus-within).
* Until the ref resolves the layer simply isn't registered (useBindings waits).
*/
export function useCloseLayer(target: () => BoxRenderable | undefined, onClose: () => void): void {
useBindings<BoxRenderable>(() => ({
target,
commands: [
{
name: 'close',
run() {
onClose()
}
}
],
bindings: [
{ key: 'escape', cmd: 'close' },
{ key: { name: 'c', ctrl: true }, cmd: 'close' }
]
}))
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Markdown — assistant/reasoning text via the NATIVE `<markdown>` renderable
* (`MarkdownRenderable`), exactly as opencode's TextPart (`routes/session/index.tsx`
* :1687 `<markdown streaming internalBlockMode="top-level" tableOptions conceal>`).
*
* Why `<markdown>` (not `<code filetype="markdown">`): the anti-flicker mechanism
* is `internalBlockMode="top-level"` — each top-level block (heading/para/list/
* table/fence) becomes its own child renderable and `_stableBlockCount` (managed
* internally) reports the settled head prefix, so stable blocks are NOT re-rendered
* per streamed delta. The old `<code>` path re-measured the whole buffer each delta
* → the content height oscillated → the scrollbar grew/shrank (the streaming
* flicker regression). `tableOptions` renders GFM tables as an aligned grid WITH
* inline markdown (bold/italic/code) inside cells — so a separate table renderer
* is unnecessary. `streaming` keeps the trailing block open while chunks append and
* finalizes it (half-open tables/fences) when flipped false.
*
* The `SyntaxStyle` is derived from the active theme (no hardcoded styles — §7.5)
* and cached by theme-object identity, so all text parts share ONE instance and
* it's rebuilt only when the skin changes (a new `Theme` object).
*/
import { RGBA, SyntaxStyle } from '@opentui/core'
import type { Theme } from '../logic/theme.ts'
import { useTheme } from './theme.tsx'
const FALLBACK = RGBA.fromHex('#E6EDF3')
const HEX6 = /^#[0-9a-fA-F]{6}$/
/** Theme colors are usually hex but may be `ansi256(n)`/`rgb(...)` after light-mode
* normalization — only hand hex to RGBA.fromHex, else fall back. */
function rgba(color: string): RGBA {
return HEX6.test(color) ? RGBA.fromHex(color) : FALLBACK
}
function buildSyntaxStyle(theme: Theme): SyntaxStyle {
const c = theme.color
return SyntaxStyle.fromStyles({
default: { fg: rgba(c.text) },
'markup.heading': { bold: true, fg: rgba(c.primary) },
'markup.heading.1': { bold: true, fg: rgba(c.primary) },
'markup.heading.2': { bold: true, fg: rgba(c.accent) },
'markup.heading.3': { bold: true, fg: rgba(c.accent) },
'markup.bold': { bold: true, fg: rgba(c.text) },
'markup.italic': { fg: rgba(c.text), italic: true },
'markup.list': { fg: rgba(c.accent) },
'markup.quote': { fg: rgba(c.muted) },
'markup.link': { fg: rgba(c.accent) },
'markup.raw': { fg: rgba(c.label) },
'markup.raw.block': { fg: rgba(c.label) }
})
}
let cache: { theme: Theme; style: SyntaxStyle } | undefined
function syntaxStyleFor(theme: Theme): SyntaxStyle {
if (cache && cache.theme === theme) return cache.style
const style = buildSyntaxStyle(theme)
cache = { style, theme }
return style
}
export function Markdown(props: { text: string; streaming?: boolean; fg?: string }) {
const theme = useTheme()
// `internalBlockMode="top-level"` is the anti-flicker mode (stable head blocks
// aren't re-rendered per delta); `tableOptions` gives native GFM tables with
// inline formatting; `fg` overrides the base text color (muted for reasoning).
// `conceal` hides the markdown markers for clean prose — mouse-selection then
// copies the RENDERED text (markers gone) via native selection, by design.
return (
<markdown
content={props.text}
syntaxStyle={syntaxStyleFor(theme())}
streaming={props.streaming ?? false}
internalBlockMode="top-level"
tableOptions={{ style: 'grid', borderColor: theme().color.border }}
conceal
fg={props.fg ?? theme().color.text}
/>
)
}
+95
View File
@@ -0,0 +1,95 @@
/**
* 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).
*
* 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 { For, Match, Show, Switch } from 'solid-js'
import type { Message } from '../logic/store.ts'
import { Markdown } from './markdown.tsx'
import { ReasoningPart } from './reasoningPart.tsx'
import { useTheme } from './theme.tsx'
import { ToolPart } from './toolPart.tsx'
const GUTTER = 2
export function MessageLine(props: { message: Message }) {
const theme = useTheme()
const m = () => props.message
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
// Role-distinct color IS the hierarchy (Ink model): the human's turn is tinted
// GOLD (label), the agent's answer is BRIGHT (text), system notes are DIM (muted).
const glyphFg = () =>
m().role === 'user' ? theme().color.label : m().role === 'assistant' ? theme().color.accent : theme().color.muted
const bodyFg = () =>
m().role === 'user' ? theme().color.label : m().role === 'system' ? theme().color.muted : theme().color.text
const hasParts = () => (m().parts?.length ?? 0) > 0
return (
// One blank line above every turn so user / assistant / tool blocks read as
// distinct turns (item: spacing). The gold-vs-bright color split does the rest.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: 1 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
{/* the role glyph is decorative — exclude it from mouse selection (item 4).
Bold so the user `` / assistant `⚕` turn boundaries pop (item 8). */}
<text selectable={false}>
<span style={{ fg: glyphFg() }}>
<b>{glyph()}</b>
</span>
</text>
</box>
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /
reasoning / tool regardless of order or stream timing, so blank lines
don't pop in and out as parts are created/merged mid-stream. */}
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: 1 }}>
<Show
when={m().role === 'assistant' && hasParts()}
fallback={
// No parts yet: the just-started streaming turn shows ONLY the caret,
// inline with the glyph (not an empty line + a dangling caret below —
// item 10 cursor misalignment); a settled row shows its flat text.
<Show
when={m().streaming && !hasParts()}
fallback={
// themed selection: a solid muted/accent bar that preserves the
// text fg (no selectionFg → the original color shows through, so a
// highlight over content reads as a clean bar, not SGR-inverse).
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: bodyFg() }}>{m().text}</span>
</text>
}
>
<text selectable={false}>
{/* streaming caret — a cursor glyph, not content (item 4) */}
<span style={{ fg: theme().color.muted }}></span>
</text>
</Show>
}
>
<For each={m().parts ?? []}>
{part => (
<Switch>
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
<Match when={part.type === 'reasoning' && part}>
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
</Match>
<Match when={part.type === 'text' && part}>
{/* ONE stable native <markdown> fed the growing text in place (no
per-delta remount → no scrollbar flicker, #2); it renders GFM
tables natively (#3). Leading/trailing blanks stripped so the
column `gap` is the sole inter-part spacing (item 5). */}
{t => <Markdown text={t().text.replace(/^\n+|\n+$/g, '')} streaming={m().streaming ?? false} />}
</Match>
</Switch>
)}
</For>
</Show>
</box>
</box>
)
}
@@ -0,0 +1,143 @@
/**
* AgentsDashboard — the delegation/subagents view (spec §2b; Ink `agentsOverlay`,
* item 15 "look into an agent trace live"). Master-detail:
* - top: the subagents tracked from the `subagent.*` stream, indented by depth;
* ↑/↓ SELECT a row (highlighted).
* - bottom: the SELECTED subagent's live trace (goal · status · model, latest
* thought, and the tool/progress/summary log) — sticky-bottom so it follows
* live; PgUp/PgDn scroll it.
* Esc/Ctrl+C close (native keymap). §8 #2 scrollbox gotchas (minHeight:0, sticky bottom).
*/
import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { createSignal, For, onMount, Show } from 'solid-js'
import type { SubagentInfo } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const PAGE = 8
function statusColor(status: string, theme: ReturnType<typeof useTheme>): string {
const c = theme().color
if (status === 'complete') return c.ok
if (status === 'tool' || status === 'working') return c.accent
if (status.includes('error') || status === 'failed') return c.error
return c.warn
}
export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () => void }) {
const theme = useTheme()
const [sel, setSel] = createSignal(0)
let rootRef: BoxRenderable | undefined
let traceBox: ScrollBoxRenderable | undefined
const count = () => props.subagents.length
const selected = () => Math.min(sel(), Math.max(0, count() - 1))
const current = () => props.subagents[selected()]
// Close (Esc/Ctrl+C) is the native keymap; select + scroll stay in the raw global
// handler below. Focus the root box on mount so the focus-within close layer is active.
onMount(() => rootRef?.focus())
useCloseLayer(
() => rootRef,
() => props.onClose()
)
useKeyboard(key => {
// `q` closes (footer advertises "Esc/q close"); Esc/Ctrl+C close via the keymap.
if (key.name === 'q') return props.onClose()
if (key.name === 'up') setSel(s => Math.max(0, s - 1))
else if (key.name === 'down') setSel(s => Math.min(Math.max(0, count() - 1), s + 1))
else if (key.name === 'pageup') traceBox?.scrollBy(-PAGE)
else if (key.name === 'pagedown') traceBox?.scrollBy(PAGE)
})
return (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
border
>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.accent}>
<b>
Agents · {count()} subagent{count() === 1 ? '' : 's'}
</b>
</text>
</box>
{/* master: the subagent list (↑/↓ select) */}
<box style={{ flexShrink: 0, flexDirection: 'column', maxHeight: 10 }}>
<Show
when={count() > 0}
fallback={<text fg={theme().color.muted}>No subagents yet delegate a task to spawn one.</text>}
>
<For each={props.subagents}>
{(sa, i) => (
<text onMouseDown={() => setSel(i())}>
<span style={{ fg: theme().color.muted }}>{' '.repeat(Math.max(0, sa.depth))}</span>
<span style={{ fg: i() === selected() ? theme().color.accent : theme().color.muted }}>
{i() === selected() ? '▸ ' : ' '}
</span>
<span style={{ fg: statusColor(sa.status, theme) }}>{`${sa.status}`}</span>
<span style={{ fg: theme().color.label }}>{` ${sa.goal || sa.id}`}</span>
<span style={{ fg: theme().color.muted }}>{sa.lastTool ? `${sa.lastTool}` : ''}</span>
</text>
)}
</For>
</Show>
</box>
{/* detail: the selected subagent's live trace */}
<box style={{ flexGrow: 1, minHeight: 0, flexDirection: 'column', borderColor: theme().color.border }} border>
<Show when={current()} fallback={<text fg={theme().color.muted}> </text>}>
{sa => (
<>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text>
<span style={{ fg: theme().color.label }}>{sa().goal || sa().id}</span>
<span style={{ fg: statusColor(sa().status, theme) }}>{` · ${sa().status}`}</span>
<span style={{ fg: theme().color.muted }}>{sa().model ? ` · ${sa().model}` : ''}</span>
</text>
</box>
<Show when={sa().thought}>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text>
<span style={{ fg: theme().color.muted }}>{`🧠 ${sa().thought}`}</span>
</text>
</box>
</Show>
<box style={{ flexGrow: 1, minHeight: 0, paddingLeft: 1 }}>
<scrollbox
ref={el => (traceBox = el)}
style={{ flexGrow: 1, minHeight: 0 }}
stickyScroll
stickyStart="bottom"
>
<Show
when={(sa().trace?.length ?? 0) > 0}
fallback={<text fg={theme().color.muted}>(no activity yet)</text>}
>
<For each={sa().trace ?? []}>
{line => (
<text>
<span style={{ fg: theme().color.muted }}>{line}</span>
</text>
)}
</For>
</Show>
</scrollbox>
</box>
</>
)}
</Show>
</box>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.muted}>Esc/q close · select · PgUp/PgDn scroll trace</text>
</box>
</box>
)
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Pager — a full-height scrollable text viewer (spec §2b `FloatBox` pager).
* Porting it unlocks the long-output slash commands (/status /logs /history
* /tools) at once. Replaces the transcript+composer while open (the App swaps it
* in on `store.state.pager`).
*
* Scrolling is driven explicitly via a GLOBAL `useKeyboard` → `scrollBy`/`scrollTo`
* (no reliance on focus); Esc/Ctrl+C close via the native keymap. Carries the §8 #2
* scrollbox gotchas (minHeight:0 wrapper+box, NO flexDirection on the box root).
*/
import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { For, onMount } from 'solid-js'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const PAGE = 10
export function Pager(props: { title: string; text: string; onClose: () => void }) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
let box: ScrollBoxRenderable | undefined
const lines = () => props.text.split('\n')
// Close (Esc/Ctrl+C) is the native keymap; scroll keys stay in the raw global
// handler below. Focus the root box on mount so the focus-within close layer is
// active (the scrollbox isn't focused — scroll is global, not focus-gated).
onMount(() => rootRef?.focus())
useCloseLayer(
() => rootRef,
() => props.onClose()
)
useKeyboard(key => {
// `q` closes (the footer advertises "Esc/q close"); Esc/Ctrl+C close via the
// keymap layer above. Scroll stays raw (not focus-gated).
if (key.name === 'q') return props.onClose()
if (!box) return
if (key.name === 'up') box.scrollBy(-1)
else if (key.name === 'down') box.scrollBy(1)
else if (key.name === 'pageup') box.scrollBy(-PAGE)
else if (key.name === 'pagedown') box.scrollBy(PAGE)
else if (key.name === 'home') box.scrollTo(0)
else if (key.name === 'end') box.scrollTo({ x: 0, y: box.scrollHeight })
})
return (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
border
>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.accent}>
<b>{props.title}</b>
</text>
</box>
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={el => (box = el)} style={{ flexGrow: 1, minHeight: 0 }}>
<For each={lines()}>{line => <text fg={theme().color.text}>{line}</text>}</For>
</scrollbox>
</box>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.muted}>Esc/q close · /PgUp/PgDn/Home/End scroll</text>
</box>
</box>
)
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Picker — a generic titled `<select>` overlay (spec §2b). Powers the model
* picker (/model) and skills hub (/skills); the chosen value runs `onPick`.
* Native select nav (↑↓/j/k/Enter); a small useKeyboard adds Esc/Ctrl+C close.
* Replaces the composer while open.
*/
import type { BoxRenderable } from '@opentui/core'
import { createMemo } from 'solid-js'
import type { PickerItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
export function Picker(props: {
title: string
items: PickerItem[]
onPick: (value: string) => void
onClose: () => void
}) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/j/k/Enter; the keymap owns Esc/Ctrl+C close.
useCloseLayer(
() => rootRef,
() => props.onClose()
)
const options = createMemo(() =>
props.items.map(it => ({ description: it.description ?? '', name: it.label, value: it.value }))
)
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.accent}>
<b>{props.title}</b>
</text>
<select
focused
options={options()}
onSelect={(_index, option) => {
if (option) props.onPick(String(option.value))
}}
backgroundColor={theme().color.statusBg}
selectedBackgroundColor={theme().color.selectionBg}
textColor={theme().color.text}
selectedTextColor={theme().color.text}
descriptionColor={theme().color.muted}
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
/>
<text fg={theme().color.muted}> select · Enter choose · Esc cancel</text>
</box>
)
}
@@ -0,0 +1,60 @@
/**
* SessionSwitcher — pick a session to resume (spec §2b; Ink
* `activeSessionSwitcher.tsx`). A native `<select>` over `session.list` rows;
* Enter resumes the chosen session (the entry runs the same resume-hydrate path
* as launch), Esc/Ctrl+C closes. Replaces the composer while open.
*/
import type { BoxRenderable } from '@opentui/core'
import { createMemo } from 'solid-js'
import type { SessionItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
export function SessionSwitcher(props: {
sessions: SessionItem[]
onPick: (sessionId: string) => void
onClose: () => void
}) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/Enter; the keymap owns Esc/Ctrl+C close.
useCloseLayer(
() => rootRef,
() => props.onClose()
)
const options = createMemo(() =>
props.sessions.map(s => ({
description: `${s.messageCount} msgs${s.preview ? ` · ${s.preview.slice(0, 60)}` : ''}`,
name: s.title || s.preview.slice(0, 48) || s.id,
value: s.id
}))
)
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.accent}>
<b> Resume a session</b>
</text>
<select
focused
options={options()}
onSelect={(_index, option) => {
if (option) props.onPick(String(option.value))
}}
backgroundColor={theme().color.statusBg}
selectedBackgroundColor={theme().color.selectionBg}
textColor={theme().color.text}
selectedTextColor={theme().color.text}
descriptionColor={theme().color.muted}
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
/>
<text fg={theme().color.muted}> select · Enter resume · Esc cancel</text>
</box>
)
}
@@ -0,0 +1,61 @@
/**
* ApprovalPrompt — dangerous-command approval (spec §8 #6). Native `<select>`
* (built-in ↑↓/j/k/Enter nav) over once/session/always/deny; a small `useKeyboard`
* adds the Esc/Ctrl+C → deny cancel path the select doesn't cover. Answered via
* `approval.respond {choice, session_id}`.
*/
import type { BoxRenderable } from '@opentui/core'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const OPTIONS = [
{ description: 'Run this command this one time', name: 'Approve once', value: 'once' },
{ description: 'Allow for the rest of this session', name: 'Approve for session', value: 'session' },
{ description: 'Always allow this command', name: 'Always approve', value: 'always' },
{ description: 'Reject this command', name: 'Deny', value: 'deny' }
]
export function ApprovalPrompt(props: {
command: string
description: string
onChoose: (choice: string) => void
onCancel: () => void
}) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/j/k/Enter over the options; the keymap owns the
// Esc/Ctrl+C → deny cancel path the select doesn't cover.
useCloseLayer(
() => rootRef,
() => props.onCancel()
)
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.warn}>
<b> Approval required</b>
</text>
<text fg={theme().color.text}>{props.command}</text>
{props.description ? <text fg={theme().color.muted}>{props.description}</text> : null}
<select
focused
options={OPTIONS}
onSelect={(_index, option) => {
if (option) props.onChoose(String(option.value))
}}
backgroundColor={theme().color.statusBg}
selectedBackgroundColor={theme().color.selectionBg}
textColor={theme().color.text}
selectedTextColor={theme().color.text}
descriptionColor={theme().color.muted}
style={{ height: 8, marginTop: 1 }}
/>
<text fg={theme().color.muted}> select · Enter confirm · Esc/Ctrl+C deny</text>
</box>
)
}
Binary file not shown.
@@ -0,0 +1,58 @@
/**
* ConfirmPrompt — a LOCAL (non-gateway) Y/N dialog (spec §2a). Driven by a local
* callback, not an RPC: y/Enter → confirm, n/Esc/Ctrl+C → cancel. Used by client
* slash commands like /clear and /new.
*/
import type { BoxRenderable } from '@opentui/core'
import { useBindings } from '@opentui/keymap/solid'
import { onMount } from 'solid-js'
import { useTheme } from '../theme.tsx'
export function ConfirmPrompt(props: { message: string; onYes: () => void; onNo: () => void }) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// No focusable child here (unlike the <select> prompts), so focus the dialog box
// itself on mount — that makes the focus-within keymap layer below active.
onMount(() => rootRef?.focus())
// Local Y/N dialog: y/Enter → confirm, n/Esc/Ctrl+C → cancel, scoped to the
// dialog box (focus-within) via the native keymap.
useBindings<BoxRenderable>(() => ({
target: () => rootRef,
commands: [
{
name: 'confirm',
run() {
props.onYes()
}
},
{
name: 'cancel',
run() {
props.onNo()
}
}
],
bindings: [
{ key: 'y', cmd: 'confirm' },
{ key: 'return', cmd: 'confirm' },
{ key: 'n', cmd: 'cancel' },
{ key: 'escape', cmd: 'cancel' },
{ key: { name: 'c', ctrl: true }, cmd: 'cancel' }
]
}))
return (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.warn}>
<b>{props.message}</b>
</text>
<text fg={theme().color.muted}>y/Enter confirm · n/Esc cancel</text>
</box>
)
}
@@ -0,0 +1,63 @@
/**
* MaskedPrompt — sudo (🔐) / secret (🔑) masked entry (spec §8 #6). OpenTUI's
* `<input>` has NO native mask (only value/placeholder/maxLength), and feeding it
* stars via `value` is a feedback loop (onInput reports the masked value), so we
* own a hidden buffer and capture raw keystrokes via `useKeyboard`, rendering '*'
* per char — the robust path for masked input (verified in the React build).
*
* Enter submits the real buffer; Esc/Ctrl+C submits empty so the agent unblocks.
*/
import { useKeyboard } from '@opentui/solid'
import { createSignal, Show } from 'solid-js'
import { useTheme } from '../theme.tsx'
export function MaskedPrompt(props: {
icon: string
label: string
sub?: string
onSubmit: (value: string) => void
onCancel: () => void
}) {
const theme = useTheme()
const [value, setValue] = createSignal('')
useKeyboard(key => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
props.onCancel()
return
}
if (key.name === 'return') {
props.onSubmit(value())
return
}
if (key.name === 'backspace') {
setValue(v => v.slice(0, -1))
return
}
const ch = key.sequence ?? ''
if (ch.length === 1 && !key.ctrl && !key.meta && ch >= ' ') setValue(v => v + ch)
})
return (
<box
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.label}>
<b>
{props.icon} {props.label}
</b>
</text>
<Show when={props.sub}>
<text fg={theme().color.muted}>{props.sub}</text>
</Show>
<box style={{ flexDirection: 'row' }}>
<text fg={theme().color.label}>{'> '}</text>
<text fg={theme().color.text}>{'*'.repeat(value().length)}</text>
<text fg={theme().color.accent}></text>
</box>
<text fg={theme().color.muted}>Enter send · Esc/Ctrl+C cancel · masked</text>
</box>
)
}
@@ -0,0 +1,110 @@
/**
* PromptOverlay — renders the active blocking prompt and binds each answer/cancel
* to the matching `*.respond` RPC (spec §4 reply contract; §8 #6 deadlock fix):
* clarify.respond {answer, request_id} · approval.respond {choice, session_id} ·
* sudo.respond {password, request_id} · secret.respond {value, request_id}.
* Every cancel path (Esc/Ctrl+C) sends the deny/empty reply so the agent unblocks.
*
* `onRespond` is the entry-wired boundary callback (fires `gateway.request`); the
* overlay also clears the store prompt so the composer returns. Narrowing is done
* with reactive `as*()` accessors so each sub-prompt gets its typed payload.
*/
import { Match, Switch } from 'solid-js'
import { deferClose } from '../../logic/defer.ts'
import type { ActivePrompt, SessionStore } from '../../logic/store.ts'
import { ApprovalPrompt } from './approvalPrompt.tsx'
import { ClarifyPrompt } from './clarifyPrompt.tsx'
import { ConfirmPrompt } from './confirmPrompt.tsx'
import { MaskedPrompt } from './maskedPrompt.tsx'
export interface PromptOverlayProps {
readonly store: SessionStore
readonly onRespond: (method: string, params: Record<string, unknown>) => void
readonly sessionId: () => string | undefined
}
export function PromptOverlay(props: PromptOverlayProps) {
const prompt = () => props.store.state.prompt
// Defer the prompt-clear (which remounts + refocuses the composer) past the
// CURRENT keystroke, so the key that answered the prompt (Enter/y/select) can't
// leak into the freshly-focused composer (e.g. `/clear`→y left "y" in the input).
const clearSoon = () => deferClose(() => props.store.clearPrompt())
const respond = (method: string, params: Record<string, unknown>) => {
props.onRespond(method, params)
clearSoon()
}
// Reactive accessor that narrows the active-prompt union to one `kind`, giving
// each <Match> branch its precise typed payload (undefined when not that kind).
function narrow<K extends ActivePrompt['kind']>(kind: K): () => Extract<ActivePrompt, { kind: K }> | undefined {
const matches = (p: ActivePrompt): p is Extract<ActivePrompt, { kind: K }> => p.kind === kind
return () => {
const p = prompt()
return p && matches(p) ? p : undefined
}
}
const asApproval = narrow('approval')
const asClarify = narrow('clarify')
const asSudo = narrow('sudo')
const asSecret = narrow('secret')
const asConfirm = narrow('confirm')
return (
<Switch>
<Match when={asApproval()}>
{p => (
<ApprovalPrompt
command={p().command}
description={p().description}
onChoose={choice => respond('approval.respond', { choice, session_id: props.sessionId() })}
onCancel={() => respond('approval.respond', { choice: 'deny', session_id: props.sessionId() })}
/>
)}
</Match>
<Match when={asClarify()}>
{p => (
<ClarifyPrompt
question={p().question}
choices={p().choices}
onAnswer={answer => respond('clarify.respond', { answer, request_id: p().requestId })}
onCancel={() => respond('clarify.respond', { answer: '', request_id: p().requestId })}
/>
)}
</Match>
<Match when={asSudo()}>
{p => (
<MaskedPrompt
icon="🔐"
label="sudo password"
onSubmit={value => respond('sudo.respond', { password: value, request_id: p().requestId })}
onCancel={() => respond('sudo.respond', { password: '', request_id: p().requestId })}
/>
)}
</Match>
<Match when={asSecret()}>
{p => (
<MaskedPrompt
icon="🔑"
label={`Secret: ${p().envVar}`}
sub={p().prompt}
onSubmit={value => respond('secret.respond', { request_id: p().requestId, value })}
onCancel={() => respond('secret.respond', { request_id: p().requestId, value: '' })}
/>
)}
</Match>
<Match when={asConfirm()}>
{p => (
<ConfirmPrompt
message={p().message}
onYes={() => {
p().onConfirm()
clearSoon()
}}
onNo={clearSoon}
/>
)}
</Match>
</Switch>
)
}
+74
View File
@@ -0,0 +1,74 @@
/**
* ReasoningPart — the model's thinking trace, collapsible (item 6; opencode's
* ReasoningPart/ReasoningHeader). Auto-EXPANDED while the turn streams (so you
* watch it think), then COLLAPSES to a one-line `▶ Thought: <title>` once the
* turn settles. Click the header to override either way.
*
* ▼ Thinking: <title> ← live (streaming), body shown
* ▶ Thought: <title> ← settled (collapsed), click to reopen
* │ <reasoning markdown> ← dim body in a left-bordered block
*
* Title is the model's leading `**bold**` line when present (opencode's
* reasoningSummary). Dim throughout — it's secondary to the answer.
*/
import { createMemo, createSignal, Show } from 'solid-js'
import { Markdown } from './markdown.tsx'
import { useScrollAnchor } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
const GUTTER = 2
/** Split a leading `**Title**\n\n body` into {title, body} (opencode reasoningSummary). */
function reasoningSummary(text: string): { title?: string; body: string } {
const s = (text ?? '').replace('[REDACTED]', '').trim()
const m = s.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
const title = m?.[1]?.trim()
if (!m || !title) return { body: s }
return { title, body: s.slice(m[0].length).trimStart() }
}
export function ReasoningPart(props: { text: string; streaming?: boolean }) {
const theme = useTheme()
const anchor = useScrollAnchor()
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
// live → expanded so you see it think; settled → collapsed. Click overrides.
const expanded = () => override() ?? !!props.streaming
const toggle = () => anchor(() => setOverride(e => !(e ?? !!props.streaming)))
const summary = createMemo(() => reasoningSummary(props.text))
const label = () => (props.streaming ? 'Thinking' : 'Thought')
return (
<Show when={summary().body || summary().title}>
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={toggle}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{expanded() ? '▼' : '▶'}</span>
</text>
</box>
{/* the header is a collapsible-section LABEL (Thinking/Thought + title)
— chrome, not the reasoning body — so a free-form drag yields only
the markdown body below, not the section label (item 4). */}
<text selectable={false}>
{/* accent chevron marks it; muted label keeps reasoning in the dim,
secondary tier alongside tool calls (Ink hierarchy). */}
<span style={{ fg: theme().color.muted }}>{label()}</span>
<Show when={summary().title}>
<span style={{ fg: theme().color.muted }}>{`: ${summary().title}`}</span>
</Show>
</text>
</box>
<Show when={expanded() && summary().body}>
<box
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
border={['left']}
borderColor={theme().color.border}
>
<Markdown text={summary().body} streaming={props.streaming ?? false} fg={theme().color.muted} />
</box>
</Show>
</box>
</Show>
)
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Scroll anchoring for collapse/expand toggles (item #4). The transcript
* <scrollbox> has stickyScroll+stickyStart="bottom": on a content-height change
* it re-pins to the bottom whenever the user hasn't manually scrolled away
* (@opentui/core ScrollBox: `if (stickyStart && !_hasManualScroll) applyStickyStart`).
* So expanding a tool/thinking block while at the bottom yanks the viewport to the
* NEW bottom — scrolling the header you just clicked up off-screen.
*
* Fix: keep scrollTop constant across the toggle. The clicked element's document
* position is unchanged (content grows BELOW it), so holding scrollTop keeps that
* header at the same screen row and simply reveals the expansion beneath it. We
* re-assert the saved offset over a few frames because the content height (and the
* sticky re-pin) only settle on the next render pass.
*/
import { type Accessor, createContext, type JSX, useContext } from 'solid-js'
import type { ScrollBoxRenderable } from '@opentui/core'
type AnchorFn = (toggle: () => void) => void
const Ctx = createContext<AnchorFn>()
export function ScrollAnchorProvider(props: {
scroll: Accessor<ScrollBoxRenderable | undefined>
children: JSX.Element
}) {
const around: AnchorFn = toggle => {
const sb = props.scroll()
if (!sb) {
toggle()
return
}
const prev = sb.scrollTop
toggle()
// Re-assert across the next few frames: the layout + sticky re-pin land on
// subsequent render passes, so a single sync restore wouldn't hold.
let n = 0
const hold = () => {
try {
sb.scrollTo(prev)
} catch {
/* renderable torn down */
}
if (++n < 4) setTimeout(hold, 16)
}
setTimeout(hold, 0)
}
return <Ctx.Provider value={around}>{props.children}</Ctx.Provider>
}
/** Wrap a collapse/expand toggle so the viewport stays put (no-op outside a provider). */
export function useScrollAnchor(): AnchorFn {
return useContext(Ctx) ?? (toggle => toggle())
}
+144
View File
@@ -0,0 +1,144 @@
/**
* StatusBar — the persistent bottom chrome (spec §3; Ink's `appChrome.tsx`
* StatusRule, item 14). One themed row pinned below the input zone:
*
* ● model ·effort ████░░░░ 42% ~/dir (branch)
*
* Fields are sourced from `store.state.info` (the `session.info` event +
* session.create/resume result; see store `SessionInfo`). Width-aware (Ink's
* `statusRuleWidths` progressive disclosure): the context bar drops on narrow
* terminals and the cwd is left-truncated (`…/tail`) so the row NEVER wraps or
* clips. Read-only chrome — no input handling here.
*/
import { useDimensions } from './dimensions.tsx'
import { createMemo, Show } from 'solid-js'
import { useTheme } from './theme.tsx'
import type { SessionStore } from '../logic/store.ts'
const HOME = process.env.HOME ?? ''
const CTX_BAR_CELLS = 8
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`; trims the provider prefix (Ink shortModelLabel). */
function shortModel(model: string): string {
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model
}
/** Reasoning effort → a compact suffix; hidden for the default/medium effort. */
function effortSuffix(effort: string | undefined, fast: boolean | undefined): string {
const parts: string[] = []
if (effort && effort !== 'medium' && effort !== 'default') parts.push(effort)
if (fast) parts.push('fast')
return parts.length ? ` ·${parts.join('·')}` : ''
}
/** Abbreviate cwd with `~` for $HOME, then collapse to the last two path segments
* (`…/lively-thrush/hermes-agent`) so deep worktree paths stay readable (Ink fmtCwdBranch). */
function shortCwd(cwd: string): string {
const home = HOME && (cwd === HOME || cwd.startsWith(HOME + '/')) ? '~' + cwd.slice(HOME.length) : cwd
const segs = home.split('/').filter(Boolean)
return segs.length <= 3 ? home : '…/' + segs.slice(-2).join('/')
}
/** Keep the TAIL of a string, prefixing with `…` when it must be clipped. */
function truncLeft(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
}
/** A unicode meter: `████░░░░` filled to `pct`% over `width` cells (Ink ctxBar). */
function ctxBar(pct: number, width: number): string {
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
return '█'.repeat(filled) + '░'.repeat(width - filled)
}
export function StatusBar(props: { store: SessionStore }) {
const theme = useTheme()
const dims = useDimensions()
const info = () => props.store.state.info
// Context-bar colour escalates with pressure (Ink ctxBarColor good→warn→bad→critical).
const ctxColor = (pct: number) =>
pct >= 92
? theme().color.statusCritical
: pct >= 80
? theme().color.statusBad
: pct >= 60
? theme().color.statusWarn
: theme().color.statusGood
const dot = () => (info().running ? '◐' : props.store.state.ready ? '●' : '○')
const dotColor = () =>
info().running ? theme().color.statusWarn : props.store.state.ready ? theme().color.statusGood : theme().color.muted
const model = () => {
const m = info().model
return m ? shortModel(m) : ''
}
const effort = () => effortSuffix(info().effort, info().fast)
const pct = () => info().contextPercent
// Progressive disclosure budget (the row is `width - 2` after the box padding).
// left = dot+space+model+effort ; the context bar shows only when there's room.
const showBar = createMemo(() => pct() !== undefined && dims().width >= 64)
const ctxText = () => {
const p = pct()
return showBar() && p !== undefined ? `${ctxBar(p, CTX_BAR_CELLS)} ${p}%` : ''
}
// Right side: cwd (branch), left-truncated to whatever the left side leaves.
const cwdFull = createMemo(() => {
const cwd = info().cwd
const c = cwd ? shortCwd(cwd) : ''
if (!c) return ''
return info().branch ? `${c} (${info().branch})` : c
})
const rightText = createMemo(() => {
const leftLen = 2 + model().length + effort().length + (showBar() ? ctxText().length + 3 : 0)
const budget = dims().width - 2 - leftLen - 2 // box padding + a 2-col gap
return budget > 4 ? truncLeft(cwdFull(), budget) : ''
})
return (
<box
style={{
flexShrink: 0,
flexDirection: 'row',
backgroundColor: theme().color.statusBg,
paddingLeft: 1,
paddingRight: 1
}}
>
{/* left: turn/connection dot + model + effort + context bar */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: dotColor() }}>{dot()}</span>
<Show when={model()}>
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
<Show when={showBar()}>
{/* a dim divider segments the bar into scannable fields (item 8).
showBar() already guarantees pct() is defined; `?? 0` only
satisfies the type and is never reached. */}
<span style={{ fg: theme().color.border }}>{' │ '}</span>
<span style={{ fg: ctxColor(pct() ?? 0) }}>{ctxBar(pct() ?? 0, CTX_BAR_CELLS)}</span>
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
</Show>
</text>
</box>
{/* spacer pushes the cwd to the right edge */}
<box style={{ flexGrow: 1, minWidth: 0 }} />
{/* right: cwd (branch), pre-truncated so the row never wraps */}
<Show when={rightText()}>
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
</text>
</box>
</Show>
</box>
)
}
+31
View File
@@ -0,0 +1,31 @@
/**
* StatusLine — the transient line just below the transcript (spec §3 chrome).
* Shows EITHER:
* - a `hint` (e.g. "Ctrl+C again to quit" — item 11), in the warn colour and
* taking priority; or
* - the kaomoji busy face/verb from `thinking.delta`/`status.update` WHILE a
* turn runs (Ink's FaceTicker), dim, cleared on `message.complete`.
* This keeps those transient indicators OUT of the transcript. Renders nothing
* when both are idle.
*/
import { Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
export function StatusLine(props: { store: SessionStore }) {
const theme = useTheme()
const line = () => props.store.state.hint ?? props.store.state.status
const isHint = () => props.store.state.hint !== undefined
return (
<Show when={line()}>
{text => (
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<span style={{ fg: isHint() ? theme().color.warn : theme().color.muted }}>{text()}</span>
</text>
</box>
)}
</Show>
)
}
+29
View File
@@ -0,0 +1,29 @@
/**
* ThemeProvider — the Solid context that exposes the current Theme to the view
* (spec v4 §7.5; mirrors opencode `context/theme.tsx`). The view reads
* `useTheme()().color.*` / `.brand.*` and NEVER hardcodes styles.
*
* The theme is a reactive accessor: when the boundary applies a skin
* (gateway.ready{skin} / skin.changed → store updates the theme), Solid
* fine-grained reactivity re-styles only the affected cells.
*/
import { type Accessor, createContext, type JSX, useContext } from 'solid-js'
import { DEFAULT_THEME, type Theme } from '../logic/theme.ts'
const ThemeContext = createContext<Accessor<Theme>>(() => DEFAULT_THEME)
export interface ThemeProviderProps {
/** Reactive theme accessor (from the store). Defaults to DEFAULT_THEME if omitted. */
readonly theme?: Accessor<Theme>
readonly children: JSX.Element
}
export function ThemeProvider(props: ThemeProviderProps) {
return <ThemeContext.Provider value={props.theme ?? (() => DEFAULT_THEME)}>{props.children}</ThemeContext.Provider>
}
/** Read the current theme inside a component. Call it (`useTheme()()`) to get the Theme. */
export function useTheme(): Accessor<Theme> {
return useContext(ThemeContext)
}
+204
View File
@@ -0,0 +1,204 @@
/**
* ToolPart — one tool call, rendered COLLAPSED by default with a clear expand
* affordance (items 2 + 7). The header shows the tool's PRIMARY ARG inline so
* you can read what it did without expanding (item 2 — "I don't see tool args"):
*
* ▶ terminal ls -la src · 0.3s (12 lines) ← collapsed (default)
* ▼ terminal ls -la src · 0.3s ← expanded header
* │ args { … } ← full args (when present)
* │ output … ← envelope-stripped body
* │ … omitted 5 lines / 234 chars ← tidy note (no raw label)
*
* `▶`/`▼` marks expandable tools; clicking the header toggles it. Running tools
* show `name …`. `resultText`/`omittedNote` are already cleaned by the store.
* Fully themed (no hardcoded styles); decorative glyphs are selectable={false}.
*/
import { type ToolPartState } from '../logic/store.ts'
import { useDimensions } from './dimensions.tsx'
import { createMemo, createSignal, For, Show } from 'solid-js'
import { collapseToolOutput, truncate } from '../logic/toolOutput.ts'
import { useScrollAnchor } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
const GUTTER = 2
/** Max output lines shown when expanded (a sane cap to avoid huge renders). */
const EXPANDED_MAX = 200
/** Max args lines shown when expanded. */
const ARGS_MAX = 16
function fmtDuration(s: number): string {
if (s < 10) return `${s.toFixed(1)}s`
if (s < 60) return `${Math.round(s)}s`
const m = Math.floor(s / 60)
const r = Math.round(s % 60)
return r ? `${m}m ${r}s` : `${m}m`
}
export function ToolPart(props: { part: ToolPartState }) {
const theme = useTheme()
const dims = useDimensions()
const anchor = useScrollAnchor()
const [expanded, setExpanded] = createSignal(false)
const toggle = () => anchor(() => setExpanded(e => !e))
const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4)
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
const lines = () => (result() ? result().split('\n') : [])
const running = () => props.part.state === 'running'
const hasOutput = () => lines().length > 0
// Parse the args JSON into top-level key→value entries for a tidy key:value
// render (no brace noise). Falls back to raw lines when it isn't an object.
const argsObj = createMemo<Record<string, unknown> | undefined>(() => {
const t = props.part.argsText
if (!t) return undefined
try {
const o: unknown = JSON.parse(t)
return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record<string, unknown>) : undefined
} catch {
return undefined
}
})
const argLine = (k: string, v: unknown) =>
`${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`.replace(/\s+/g, ' ')
const argEntries = createMemo(() => Object.entries(argsObj() ?? {}))
// Hide the args block when it adds nothing over the header: a single field
// whose value is already the primary-arg preview (item 2 judge nit — terminal's
// `command` is redundant). Show it for multi-field tools (edits, reads w/ range).
const showArgs = createMemo(() => {
const e = argEntries()
if (argsObj() === undefined) return !!props.part.argsText // unparsed → show raw
if (e.length === 0) return false
const only = e.length === 1 ? e[0] : undefined
if (only) {
const v = only[1]
const vs = (typeof v === 'string' ? v : JSON.stringify(v)).trim()
return vs !== (props.part.argsPreview ?? '').trim()
}
return true
})
// Expandable when there's a body to reveal beyond the header (output or args).
const collapsible = () => !running() && (lines().length > 1 || showArgs())
// Header subtitle: the primary-arg preview (item 2), else explicit summary, else first line.
const subtitle = () =>
props.part.error ? `${props.part.error}` : props.part.argsPreview || props.part.summary || lines()[0] || ''
const body = createMemo(() => collapseToolOutput(result(), EXPANDED_MAX, bodyWidth() - 2))
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
// as the dim, secondary tier below the bright assistant answer (Ink hierarchy).
const headColor = () => (props.part.error ? theme().color.error : theme().color.accent)
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
return (
// Spacing between parts is owned by the parts column (gap), not per-part
// margins — so a tool appearing mid-stream doesn't shift the layout (item 5).
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
{/* header — clickable to toggle when there's expandable output/args */}
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && toggle()}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: headColor() }}>{headGlyph()}</span>
</text>
</box>
<box style={{ flexDirection: 'row', flexGrow: 1, minWidth: 0 }}>
{/* the whole header row is a collapsed SUMMARY (tool name + args-preview
+ duration + "(N lines)") — chrome, not the copyable body — so a
free-form drag over a tool yields only the expanded output/args
content, never the header label (item 4). */}
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{props.part.name}</span>
<Show when={running()}>
<span style={{ fg: theme().color.muted }}> </span>
</Show>
<Show when={!running() && subtitle()}>
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
{` ${truncate(subtitle(), subWidth())}`}
</span>
</Show>
<Show when={!running() && props.part.duration !== undefined}>
<span style={{ fg: theme().color.muted }}>{` · ${fmtDuration(props.part.duration ?? 0)}`}</span>
</Show>
<Show when={collapsible() && !expanded() && lines().length > 1}>
<span style={{ fg: theme().color.muted }}>{` (${lines().length} lines)`}</span>
</Show>
</text>
</box>
</box>
{/* expanded body — args block (when present) then output block, inside a
single left-bordered column (a `│` rule, not a bg fill — opencode's
BlockTool style; also renders faithfully and reads cleaner). */}
<Show when={collapsible() && expanded()}>
<box
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
border={['left']}
borderColor={props.part.error ? theme().color.error : theme().color.border}
>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Show when={showArgs()}>
{/* section label — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.label }}>args</span>
</text>
{/* parsed key: value lines (tidy), or raw argsText when unparseable */}
<Show
when={argsObj() !== undefined}
fallback={
<For each={(props.part.argsText ?? '').split('\n').slice(0, ARGS_MAX)}>
{line => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>{truncate(line, bodyWidth() - 2)}</span>
</text>
)}
</For>
}
>
<For each={argEntries().slice(0, ARGS_MAX)}>
{([k, v]) => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>{truncate(argLine(k, v), bodyWidth() - 2)}</span>
</text>
)}
</For>
<Show when={argEntries().length > ARGS_MAX}>
{/* overflow annotation — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`… +${argEntries().length - ARGS_MAX} more`}</span>
</text>
</Show>
</Show>
</Show>
<Show when={showArgs() && hasOutput()}>
{/* section label — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.label }}>output</span>
</text>
</Show>
{/* output body lines are the copyable content → themed selection bar
(preserves fg; same token as message text) (item: theme highlight). */}
<For each={body().lines}>
{line => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>{line}</span>
</text>
)}
</For>
{/* truncation annotations — chrome (the "… omitted N" / "… +N more
lines" notes are not part of the real output body) (item 4). */}
<Show when={props.part.omittedNote}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{`… omitted ${props.part.omittedNote}`}</span>
</text>
</Show>
<Show when={body().hiddenLines > 0 && !props.part.omittedNote}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`… +${body().hiddenLines} more lines`}</span>
</text>
</Show>
</box>
</box>
</Show>
</box>
)
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 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.
*
* A `ScrollAnchorProvider` gives collapse/expand toggles (tool/thinking) a handle
* to hold the viewport in place so expanding doesn't yank to the bottom (#4).
*/
import type { ScrollBoxRenderable } from '@opentui/core'
import { createSignal, For, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { HomeHint } from './homeHint.tsx'
import { MessageLine } from './messageLine.tsx'
import { ScrollAnchorProvider } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
export function Transcript(props: { store: SessionStore }) {
const [scroll, setScroll] = createSignal<ScrollBoxRenderable | undefined>()
const theme = useTheme()
const dropped = () => props.store.state.dropped
const sid = () => props.store.state.sessionId
return (
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
<ScrollAnchorProvider scroll={scroll}>
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
<Show when={props.store.state.messages.length === 0}>
<HomeHint store={props.store} />
</Show>
{/* Honest truncation notice: the rolling cap hides the OLDEST rows from the
DISPLAY (never the model's context — that lives on the gateway). Point to
the dashboard for the full transcript. selectable=false → it's chrome,
excluded from copy/selection. */}
<Show when={dropped() > 0}>
<text selectable={false} style={{ fg: theme().color.muted }}>
{`${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
</text>
</Show>
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
</ScrollAnchorProvider>
</scrollbox>
</box>
)
}