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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }
|
||||
]
|
||||
}))
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user