@opentui/keymap@0.3.2 was installed but unused (the spec said we'd use it). Wire it natively: createDefaultOpenTuiKeymap(renderer) + <KeymapProvider> at the render root, and a useCloseLayer(target,onClose) helper that registers a focus-within Esc/Ctrl+C → close layer. Migrated the close handling of sessionSwitcher, picker, approvalPrompt (close-only), confirmPrompt (y/n via confirm/cancel commands), and pager + agentsDashboard (close via keymap; scroll/select stay raw — not cleanly focus-gated). Overlays gain a root ref + focus-on-mount so the focus-within layer activates. q-close re-added to pager/dashboard (footer advertises it). Composer history/refocus + masked prompt + the Ctrl+C quit machine stay raw by design (need the in-flight keystroke / careful state). Test harness gains a withKeymap() wrapper so view tests mount under a provider. 91 pass; live-verified /sessions + /tools Esc-close and composer focus recovery after.
458 lines
19 KiB
TypeScript
458 lines
19 KiB
TypeScript
/**
|
|
* Entry — the single boundary edge (spec v4 §3.1). This is the ONE place that:
|
|
* - acquires the renderer (acquireRelease + Deferred-on-destroy),
|
|
* - creates the Solid store,
|
|
* - wires GatewayService.subscribe -> store.apply (Effect->Solid contact #2),
|
|
* - does the one-line `render(() => <App/>, renderer)` bridge (contact #1),
|
|
* - (live) bootstraps a session and optionally submits an initial prompt,
|
|
* - blocks until the renderer is destroyed (user quit),
|
|
* and at the bottom PROVIDES the layers and runs (`Effect.provide(AppLayer)`).
|
|
*
|
|
* Backend selection (import.meta.main):
|
|
* - default → the LIVE `liveGatewayLayer` (spawns the real Python
|
|
* `tui_gateway`); after `gateway.ready` it `session.create`s and, if an
|
|
* initial prompt is given (HERMES_TUI_PROMPT or argv), `prompt.submit`s it.
|
|
* The composer lands in Phase 2 — until then the initial prompt is how a
|
|
* streamed reply is driven into the transcript (spec Phase-1 smoke).
|
|
* - HERMES_TUI_FAKE=1 → the scripted FakeGateway "hello" (offline dev/CI).
|
|
*
|
|
* The body of `run` does not change when the backend swaps — that's the point of
|
|
* the layer; only `makeAppLayer(...)` differs at the edge.
|
|
*/
|
|
import { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui'
|
|
import { KeymapProvider } from '@opentui/keymap/solid'
|
|
import { render } from '@opentui/solid'
|
|
import { Deferred, Duration, Effect } from 'effect'
|
|
import { writeFileSync } from 'node:fs'
|
|
|
|
import { readClipboardImage, writeClipboard } from '../boundary/clipboard.ts'
|
|
import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts'
|
|
import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
|
import { getLog } from '../boundary/log.ts'
|
|
import { acquireRenderer } from '../boundary/renderer.ts'
|
|
import { makeAppLayer } from '../boundary/runtime.ts'
|
|
import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts'
|
|
import { createPasteStore } from '../logic/pastes.ts'
|
|
import { mapResumeHistory, mapSessionList } from '../logic/resume.ts'
|
|
import { dispatchSlash, mapCompletions, planCompletion, readReplaceFrom, type SlashContext } from '../logic/slash.ts'
|
|
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
|
import { App } from '../view/App.tsx'
|
|
import { ThemeProvider } from '../view/theme.tsx'
|
|
import { makeFakeGatewayLayer, type FakeGatewayController } from './fakeGateway.ts'
|
|
|
|
export interface TuiInput {
|
|
/** Mouse tracking on/off. */
|
|
readonly mouse: boolean
|
|
/** Skip the live session bootstrap (the fake backend drives the stream itself). */
|
|
readonly fake: boolean
|
|
/** Terminal width passed to `session.create` (Ink uses the live cols; 80 is a fine default). */
|
|
readonly cols: number
|
|
/** Optional initial prompt submitted once the session is ready — the Phase-1 stand-in for the composer. */
|
|
readonly initialPrompt?: string
|
|
/** Resume a session instead of creating one: a session id, or 'recent'/'last' (→ session.most_recent). */
|
|
readonly resumeId?: string
|
|
}
|
|
|
|
const READY_POLL = Duration.millis(100)
|
|
const READY_TIMEOUT_MS = 20_000
|
|
/** Window after a Ctrl+C in which a second Ctrl+C quits the TUI (item 11). */
|
|
const QUIT_WINDOW_MS = 3_000
|
|
|
|
/**
|
|
* Resume a session INTO the store: buffer live events across the `session.resume`
|
|
* RPC, then replace history + replay (gotcha §8 #5 tool rows handled by
|
|
* mapResumeHistory). Shared by the launch bootstrap and the session switcher.
|
|
* Timed (rpc_ms / hydrate_ms) for the resume profile.
|
|
*/
|
|
/**
|
|
* Record the CURRENT session id in `HERMES_TUI_ACTIVE_SESSION_FILE` (item #5).
|
|
* The launcher reads this on exit to print the right "Resume this session with…"
|
|
* epilogue (hermes_cli/main.py `_print_tui_exit_summary`). The Ink TUI writes it on
|
|
* every session change (useSessionLifecycle.writeActiveSessionFile); the native
|
|
* engine must too, or the launcher falls back to the INITIAL launch session and
|
|
* shows resume info for the wrong session after a `/session` switch.
|
|
*/
|
|
const writeActiveSession = (sid: string | undefined) => {
|
|
const file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE
|
|
if (!file || !sid) return
|
|
try {
|
|
writeFileSync(file, JSON.stringify({ session_id: sid }), { mode: 0o600 })
|
|
} catch (cause) {
|
|
getLog().warn('bootstrap', 'active-session-file write failed', { cause: String(cause) })
|
|
}
|
|
}
|
|
|
|
const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: string, cols: number) =>
|
|
Effect.gen(function* () {
|
|
writeActiveSession(sid) // the session we're switching to is now the active one (#5)
|
|
store.setSessionId(sid)
|
|
store.beginBuffer()
|
|
const t0 = Date.now()
|
|
const resumed = yield* gateway.request<{ messages?: unknown; info?: Record<string, unknown> }>('session.resume', {
|
|
cols,
|
|
session_id: sid,
|
|
// native engine renders tools collapsed → safe to fold each tool's capped
|
|
// result into the resume snapshot so resumed turns render like live (item 1).
|
|
with_tool_output: true
|
|
})
|
|
const t1 = Date.now()
|
|
const snapshot = mapResumeHistory(resumed?.messages)
|
|
store.commitSnapshot(snapshot)
|
|
if (resumed?.info) store.applyInfo(resumed.info)
|
|
getLog().info('bootstrap', 'session resumed', {
|
|
count: snapshot.length,
|
|
hydrate_ms: Date.now() - t1,
|
|
rpc_ms: t1 - t0,
|
|
sid
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Live session bootstrap: wait for the unsolicited `gateway.ready` handshake,
|
|
* then either RESUME a session (hydrate its transcript — incl. tool rows — via
|
|
* the snapshot, buffering live events across the RPC) or CREATE a fresh one, and
|
|
* (if given) submit the initial prompt. Forked into the entry scope so it runs
|
|
* concurrently with the render + the quit-await. Any failure is logged and
|
|
* swallowed — a bootstrap hiccup must never tear down the rendered UI.
|
|
*/
|
|
const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, input: TuiInput) =>
|
|
Effect.gen(function* () {
|
|
const log = getLog()
|
|
let waited = 0
|
|
while (!store.state.ready && waited < READY_TIMEOUT_MS) {
|
|
yield* Effect.sleep(READY_POLL)
|
|
waited += 100
|
|
}
|
|
if (!store.state.ready) {
|
|
log.warn('bootstrap', 'no gateway.ready within timeout', { waited })
|
|
return
|
|
}
|
|
|
|
let sid: string | undefined
|
|
if (input.resumeId) {
|
|
sid = input.resumeId
|
|
if (sid === 'recent' || sid === 'last') {
|
|
const recent = yield* gateway.request<{ session_id?: string }>('session.most_recent', {})
|
|
sid = recent?.session_id
|
|
}
|
|
if (!sid) {
|
|
log.warn('bootstrap', 'no session to resume', { resumeId: input.resumeId })
|
|
return
|
|
}
|
|
yield* resumeInto(gateway, store, sid, input.cols)
|
|
} else {
|
|
const created = yield* gateway.request<{ session_id?: string; info?: Record<string, unknown> }>(
|
|
'session.create',
|
|
{ cols: input.cols }
|
|
)
|
|
sid = created?.session_id ?? gateway.sessionId()
|
|
if (!sid) {
|
|
log.warn('bootstrap', 'session.create returned no session_id')
|
|
return
|
|
}
|
|
if (created?.info) store.applyInfo(created.info)
|
|
writeActiveSession(sid) // record the new session for the launcher's exit epilogue (#5)
|
|
store.setSessionId(sid)
|
|
log.info('bootstrap', 'session created', { sid })
|
|
}
|
|
|
|
// Tools/skills/MCP catalog for the home-screen panel (item 9) — best-effort,
|
|
// never blocks startup if the RPC is missing/old.
|
|
const catalog = yield* gateway
|
|
.request<unknown>('startup.catalog', { session_id: sid })
|
|
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
|
if (catalog) store.setCatalog(catalog)
|
|
|
|
const prompt = input.initialPrompt?.trim()
|
|
if (prompt) {
|
|
store.pushUser(prompt)
|
|
yield* gateway.request('prompt.submit', { session_id: sid, text: prompt })
|
|
}
|
|
}).pipe(Effect.catchCause(cause => Effect.sync(() => getLog().warn('bootstrap', 'failed', { cause: String(cause) }))))
|
|
|
|
/** The entry Effect. Mirrors opencode `app.tsx:177` `run = Effect.fn("Tui.run")`. */
|
|
export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|
yield* Effect.scoped(
|
|
Effect.gen(function* () {
|
|
// Solid side: the store + reducer. Created here, lives in Solid-land.
|
|
const store = createSessionStore()
|
|
|
|
// Prompt history (item 6): scoped to the launch directory so prior prompts
|
|
// from the same project dir are recallable (Up/Down), without bleeding
|
|
// across different dirs. process.cwd() is the user's launch dir under the
|
|
// real launcher.
|
|
const historyCwd = process.cwd()
|
|
const history = createPromptHistory({
|
|
initial: loadDirHistory(historyCwd),
|
|
persist: dirHistoryPersister(historyCwd)
|
|
})
|
|
|
|
// Pasted-text store — created ONCE here so it survives the composer
|
|
// remounting (overlay open/close); a per-composer store would lose a
|
|
// pending `[Pasted text #N]` mid-compose and submit would send it literally.
|
|
const pasteStore = createPasteStore()
|
|
|
|
// Contact point #2: boundary pushes decoded events into the Solid store.
|
|
const gateway = yield* GatewayService
|
|
yield* gateway.subscribe(event => store.apply(event))
|
|
|
|
// ── Ctrl+C state machine (item 11) ──────────────────────────────────
|
|
// While a turn runs, the first Ctrl+C STOPS the agent (session.interrupt);
|
|
// a second Ctrl+C within QUIT_WINDOW_MS (or when idle) KILLS the TUI. The
|
|
// debounce stops a stray Ctrl+C from nuking the session (opencode's
|
|
// double-press model; the user's preferred behaviour).
|
|
let quitArmed = false
|
|
let quitTimer: ReturnType<typeof setTimeout> | undefined
|
|
let doQuit = () => {} // assigned once the renderer exists
|
|
const disarmQuit = () => {
|
|
quitArmed = false
|
|
if (quitTimer) clearTimeout(quitTimer)
|
|
quitTimer = undefined
|
|
store.setHint(undefined)
|
|
}
|
|
const armQuit = (message: string) => {
|
|
quitArmed = true
|
|
store.setHint(message)
|
|
if (quitTimer) clearTimeout(quitTimer)
|
|
quitTimer = setTimeout(disarmQuit, QUIT_WINDOW_MS)
|
|
}
|
|
const interruptTurn = () => {
|
|
const sid = gateway.sessionId()
|
|
if (!sid) return
|
|
Effect.runFork(
|
|
gateway
|
|
.request('session.interrupt', { session_id: sid })
|
|
.pipe(
|
|
Effect.catchCause(cause => Effect.sync(() => getLog().warn('interrupt', 'failed', { cause: String(cause) })))
|
|
)
|
|
)
|
|
}
|
|
const onCtrlC = () => {
|
|
if (quitArmed) {
|
|
disarmQuit()
|
|
doQuit()
|
|
return
|
|
}
|
|
if (store.state.info.running) {
|
|
interruptTurn()
|
|
armQuit('⏹ stopped — Ctrl+C again to quit')
|
|
} else {
|
|
armQuit('Ctrl+C again to quit')
|
|
}
|
|
}
|
|
|
|
// Transient hint that auto-clears (used by copy/image-paste feedback).
|
|
const flashHint = (message: string, ms = 1500) => {
|
|
store.setHint(message)
|
|
setTimeout(() => {
|
|
if (store.state.hint === message) store.setHint(undefined)
|
|
}, ms)
|
|
}
|
|
|
|
// Copy a mouse selection to the clipboard (item 1) — OSC 52 + native command.
|
|
const onCopySelection = (text: string) => {
|
|
void writeClipboard(text)
|
|
flashHint('Copied to clipboard')
|
|
}
|
|
|
|
// Paste an IMAGE (item 1): read the clipboard image and attach it to the
|
|
// session (image.attach_bytes); the next prompt.submit picks it up.
|
|
const onImagePaste = () => {
|
|
void (async () => {
|
|
const img = await readClipboardImage()
|
|
if (!img) {
|
|
flashHint('No image in clipboard', 2000)
|
|
return
|
|
}
|
|
const sid = gateway.sessionId()
|
|
if (!sid) {
|
|
flashHint('No session for image', 2000)
|
|
return
|
|
}
|
|
try {
|
|
await Effect.runPromise(
|
|
gateway.request('image.attach_bytes', { content_base64: img.data, filename: 'pasted.png', session_id: sid })
|
|
)
|
|
flashHint('🖼 image attached — type a message and send', 3000)
|
|
} catch {
|
|
flashHint('Image attach failed', 2000)
|
|
}
|
|
})()
|
|
}
|
|
|
|
// A blocking prompt owns Ctrl+C (→ cancel); otherwise the state machine above runs.
|
|
const { renderer, shutdown } = yield* acquireRenderer({
|
|
mouse: input.mouse,
|
|
isBlocked: () => store.state.prompt !== undefined,
|
|
onCtrlC,
|
|
onCopySelection
|
|
})
|
|
doQuit = () => {
|
|
if (!renderer.isDestroyed) renderer.destroy()
|
|
}
|
|
|
|
// Native keymap host (Phase 3): one keymap bound to this renderer, provided
|
|
// to the whole Solid tree via <KeymapProvider>. Overlays/prompts register
|
|
// close (and confirm) layers against it through useCloseLayer/useBindings.
|
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
|
|
// Submit a user turn: the service value is in hand, so `gateway.request(...)`
|
|
// is Effect<…, never> — fire it detached with runFork; failures are logged.
|
|
const submitPrompt = (text: string) => {
|
|
store.pushUser(text)
|
|
const sid = gateway.sessionId()
|
|
if (!sid) {
|
|
getLog().warn('submit', 'no session yet — dropping prompt', { text })
|
|
return
|
|
}
|
|
Effect.runFork(
|
|
gateway
|
|
.request('prompt.submit', { session_id: sid, text })
|
|
.pipe(
|
|
Effect.catchCause(cause => Effect.sync(() => getLog().warn('submit', 'failed', { cause: String(cause) })))
|
|
)
|
|
)
|
|
}
|
|
|
|
// Slash dispatch context (Solid logic; the boundary just hands it a
|
|
// Promise-returning `request` + the host capabilities it needs).
|
|
const slashCtx: SlashContext = {
|
|
clearTranscript: () => store.clearTranscript(),
|
|
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
|
|
listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList),
|
|
logTail: () =>
|
|
getLog()
|
|
.tail(200)
|
|
.map(e => `${e.scope}: ${e.msg}`),
|
|
openDashboard: () => store.openDashboard(),
|
|
openPager: (title, text) => store.openPager(title, text),
|
|
openPicker: picker => store.openPicker(picker),
|
|
openSwitcher: sessions => store.openSwitcher(sessions),
|
|
pushSystem: text => store.pushSystem(text),
|
|
quit: () => {
|
|
if (!renderer.isDestroyed) renderer.destroy()
|
|
},
|
|
request: (method, params) => Effect.runPromise(gateway.request(method, params)),
|
|
sessionId: () => gateway.sessionId(),
|
|
submit: submitPrompt
|
|
}
|
|
|
|
// Resume a chosen session (session switcher pick) — same hydrate path as launch.
|
|
const onResume = (resumeSid: string) => {
|
|
Effect.runFork(
|
|
resumeInto(gateway, store, resumeSid, input.cols).pipe(
|
|
Effect.catchCause(cause => Effect.sync(() => getLog().warn('resume', 'failed', { cause: String(cause) })))
|
|
)
|
|
)
|
|
}
|
|
|
|
// The composer's submit: route `/command` through the slash ladder, else a prompt.
|
|
const submit = (text: string) => {
|
|
if (text.startsWith('/')) void dispatchSlash(text, slashCtx)
|
|
else submitPrompt(text)
|
|
}
|
|
|
|
// Live completions (items 5 + 13): a `/command [args]` line queries
|
|
// `complete.slash` (the gateway completes names AND args); a trailing
|
|
// path-like word queries `complete.path` (file/@-mention tagging). The
|
|
// accepted item replaces from the gateway's `replace_from` (or the token
|
|
// start), so only the relevant token is spliced — not the whole line.
|
|
// Fired per keystroke (a debounce is a polish item).
|
|
const onType = (text: string) => {
|
|
const plan = planCompletion(text)
|
|
if (!plan) {
|
|
store.clearCompletions()
|
|
return
|
|
}
|
|
Effect.runPromise(gateway.request(plan.method, plan.params))
|
|
.then(result => store.setCompletions(mapCompletions(result), readReplaceFrom(result, plan.from)))
|
|
.catch(() => store.clearCompletions())
|
|
}
|
|
|
|
// Blocking-prompt replies (clarify/approval/sudo/secret `*.respond`). Same
|
|
// detached-runFork pattern; failures logged, never thrown into the view.
|
|
const respond = (method: string, params: Record<string, unknown>) => {
|
|
Effect.runFork(
|
|
gateway
|
|
.request(method, params)
|
|
.pipe(
|
|
Effect.catchCause(cause =>
|
|
Effect.sync(() => getLog().warn('respond', 'failed', { cause: String(cause), method }))
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
// Live backend: drive a session (create + optional initial prompt) concurrently.
|
|
if (!input.fake) yield* Effect.forkScoped(bootstrapSession(gateway, store, input))
|
|
|
|
// Contact point #1: the single render bridge. After this, the screen is Solid's.
|
|
// The theme is sourced reactively from the store (skin events update it).
|
|
yield* Effect.promise(() =>
|
|
render(
|
|
() => (
|
|
<KeymapProvider keymap={keymap}>
|
|
<ThemeProvider theme={() => store.state.theme}>
|
|
<App
|
|
store={store}
|
|
onSubmit={submit}
|
|
onType={onType}
|
|
onRespond={respond}
|
|
onResume={onResume}
|
|
sessionId={() => gateway.sessionId()}
|
|
history={history}
|
|
onImagePaste={onImagePaste}
|
|
pasteStore={pasteStore}
|
|
/>
|
|
</ThemeProvider>
|
|
</KeymapProvider>
|
|
),
|
|
renderer
|
|
)
|
|
)
|
|
|
|
// Block until the renderer is destroyed (Ctrl+C / quit); finalizers then run.
|
|
yield* Deferred.await(shutdown)
|
|
})
|
|
)
|
|
})
|
|
|
|
/** Scripted "hello" stream so the fake backend paints a non-empty frame offline. */
|
|
function streamHello(controller: FakeGatewayController): void {
|
|
controller.emit({ type: 'gateway.ready' })
|
|
controller.emit({ type: 'message.start' })
|
|
for (const chunk of ['Hi ', 'there, ', 'glitch!']) {
|
|
controller.emit({ type: 'message.delta', payload: { text: chunk } })
|
|
}
|
|
controller.emit({ type: 'message.complete' })
|
|
}
|
|
|
|
const TRUE_RE = /^(?:1|true|yes|on)$/i
|
|
|
|
if (import.meta.main) {
|
|
const fake = TRUE_RE.test(process.env.HERMES_TUI_FAKE?.trim() ?? '')
|
|
const cols = process.stdout.columns || 80
|
|
const initialPrompt = process.env.HERMES_TUI_PROMPT?.trim() || process.argv.slice(2).join(' ').trim()
|
|
const resumeId = process.env.HERMES_TUI_RESUME?.trim()
|
|
// Mouse on by default (opencode parity: wheel-scroll the transcript, drag the
|
|
// scrollbar, click-to-expand tools, text-aware selection). HERMES_TUI_MOUSE=0 opts out.
|
|
const mouse = !/^(?:0|false|no|off)$/i.test(process.env.HERMES_TUI_MOUSE?.trim() ?? '')
|
|
const base = { mouse, fake, cols }
|
|
const withPrompt = initialPrompt ? { ...base, initialPrompt } : base
|
|
const input: TuiInput = resumeId ? { ...withPrompt, resumeId } : withPrompt
|
|
|
|
const onFatal = (error: unknown) => {
|
|
getLog().error('entry', 'fatal', { error: String(error) })
|
|
process.exitCode = 1
|
|
}
|
|
|
|
if (fake) {
|
|
const { layer, controller } = makeFakeGatewayLayer()
|
|
// Drive the fake stream shortly after mount so the subscription is live.
|
|
setTimeout(() => streamHello(controller), 50)
|
|
Effect.runPromise(run(input).pipe(Effect.provide(makeAppLayer(layer)))).catch(onFatal)
|
|
} else {
|
|
Effect.runPromise(run(input).pipe(Effect.provide(makeAppLayer(liveGatewayLayer)))).catch(onFatal)
|
|
}
|
|
}
|