feat(opentui-v2): Phase 4a — slash command system + local confirm dialog

The composer now routes `/command` through the Ink-parity dispatch ladder
instead of submitting it as a prompt (spec §1):

- logic/slash.ts: parseSlash + dispatchSlash — client-local command →
  slash.exec {command, session_id} (output → system line) → on reject
  command.dispatch {arg, name, session_id} with typed handling
  (exec/plugin→system · alias→re-dispatch · skill/send→submit a turn ·
  prefill→notice). 6 client commands: help/quit/exit/clear/new/logs.
- /help renders the live `commands.catalog` (reads the `pairs` shape).
- view/prompts/confirmPrompt.tsx + store.setConfirm: a LOCAL (non-gateway) Y/N
  dialog for /clear and /new; store gains pushSystem + clearTranscript.
- entry: a Promise-returning `request` adapter + the SlashContext wiring (quit →
  renderer.destroy, confirm, clearTranscript, logTail, submit).

Also fixes a keystroke-leak: the key that ANSWERED a prompt was bleeding into the
freshly-refocused composer (`/clear`→y left "y" in the input, breaking the next
`/quit`). PromptOverlay now defers the prompt-clear (composer remount) past the
current keystroke — this hardens every Phase 3 prompt too.

Verified: bun run check green (36 tests / 6 files) — slash.test covers parse + the
full ladder against a fake context. LIVE tmux: /help → full gateway catalog;
/version → slash.exec output; /clear → confirm → cleared, no key-leak (typed "hi"
not "yhi"); /quit → clean quit, child reaped. Remaining TUI-only commands,
completions, pager routing, and session resume are 4b/4c. Smoke P4 + matrix updated.
This commit is contained in:
alt-glitch 2026-06-08 15:20:06 +00:00
parent d01b573796
commit 87634e19fd
6 changed files with 375 additions and 6 deletions

View file

@ -27,6 +27,7 @@ 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 { dispatchSlash, 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'
@ -96,10 +97,9 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
const gateway = yield* GatewayService
yield* gateway.subscribe(event => store.apply(event))
// Composer submit: a plain callback the Solid view calls. The service value
// is already in hand, so `gateway.request(...)` is Effect<…, never> — fire it
// detached with runFork; failures are logged, never thrown into the view.
const submit = (text: string) => {
// 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) {
@ -115,6 +115,30 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
)
}
// 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),
logTail: () =>
getLog()
.tail(40)
.map(e => `${e.scope}: ${e.msg}`),
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
}
// 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)
}
// 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>) => {

View file

@ -0,0 +1,154 @@
/**
* Slash command system the SOLID side (spec §1; mirrors Ink
* `app/createSlashHandler.ts` + `domain/slash.ts`). Plain functions/data, NOT
* Effect; the boundary injects a Promise-returning `request` so dispatch can call
* `slash.exec` / `command.dispatch` / `commands.catalog`.
*
* Dispatch ladder (Ink parity):
* 1. client-local command (the TUI-only set handled in-process)
* 2. `slash.exec {command, session_id}` `{output, warning?}` system line
* 3. on reject `command.dispatch {arg, name, session_id}` typed action
* (exec/plugin system · alias re-dispatch · skill/send submit a turn ·
* prefill notice). Pager routing for long output lands with Phase 5a; for
* now long output is shown as a (multi-line) system message.
*/
export interface ParsedSlash {
name: string
arg: string
}
/** Parse `/name rest…` → {name, arg}; null if not a slash command. */
export function parseSlash(input: string): ParsedSlash | null {
if (!input.startsWith('/')) return null
const body = input.slice(1).trimStart()
if (!body) return null
const sp = body.indexOf(' ')
return sp === -1 ? { arg: '', name: body } : { arg: body.slice(sp + 1).trim(), name: body.slice(0, sp) }
}
/** The host capabilities the dispatcher needs (wired by the entry boundary). */
export interface SlashContext {
/** Server RPC (resolves with the result, rejects on GatewayError). */
readonly request: (method: string, params: Record<string, unknown>) => Promise<unknown>
readonly sessionId: () => string | undefined
readonly pushSystem: (text: string) => void
/** Submit a user turn (skill/send dispatch results). */
readonly submit: (text: string) => void
/** Open a local Y/N confirm; `onConfirm` runs on Yes. */
readonly confirm: (message: string, onConfirm: () => void) => void
readonly clearTranscript: () => void
readonly quit: () => void
/** Recent log lines for `/logs` (the ring buffer). */
readonly logTail: () => string[]
}
function readStr(value: unknown, key: string): string | undefined {
if (!value || typeof value !== 'object') return undefined
const v = (value as { [k: string]: unknown })[key]
return typeof v === 'string' ? v : undefined
}
const CLIENT_HELP = [
'/help — list commands',
'/clear, /new — clear the transcript (confirm)',
'/logs — recent engine log lines',
'/quit, /exit — quit',
'(other /commands run on the gateway)'
].join('\n')
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
/** The TUI-only client commands (run in-process, never hit the gateway). */
const CLIENT: Record<string, ClientHandler> = {
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
exit: (_arg, ctx) => ctx.quit(),
help: async (_arg, ctx) => {
// Prefer the live catalog; fall back to the client list if it's unavailable.
try {
const cat = await ctx.request('commands.catalog', {})
ctx.pushSystem(renderCatalog(cat) || CLIENT_HELP)
} catch {
ctx.pushSystem(CLIENT_HELP)
}
},
logs: (_arg, ctx) => ctx.pushSystem(ctx.logTail().slice(-40).join('\n') || '(log empty)'),
new: (_arg, ctx) => ctx.confirm('Start fresh? (clears the transcript)', ctx.clearTranscript),
quit: (_arg, ctx) => ctx.quit()
}
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
* The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }`
* (tui_gateway/server.py `commands.catalog`). */
function renderCatalog(cat: unknown): string {
if (!cat || typeof cat !== 'object') return ''
const pairs = (cat as { pairs?: unknown }).pairs
if (!Array.isArray(pairs)) return ''
const lines = pairs
.map(pair => {
if (!Array.isArray(pair) || typeof pair[0] !== 'string') return null
const desc = typeof pair[1] === 'string' ? pair[1] : ''
return desc ? `${pair[0]}${desc}` : pair[0]
})
.filter((l): l is string => l !== null)
return lines.length ? lines.join('\n') : ''
}
function handleDispatchResult(parsed: ParsedSlash, raw: unknown, ctx: SlashContext): void {
const type = readStr(raw, 'type')
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
switch (type) {
case 'exec':
case 'plugin':
ctx.pushSystem(readStr(raw, 'output') || '(no output)')
return
case 'alias': {
const target = readStr(raw, 'target')
if (target) void dispatchSlash(`/${target}${argTail}`, ctx)
return
}
case 'skill':
case 'send': {
const notice = readStr(raw, 'notice')
if (notice) ctx.pushSystem(notice)
const message = readStr(raw, 'message')
if (message?.trim()) ctx.submit(message)
else ctx.pushSystem(`/${parsed.name}: empty message`)
return
}
case 'prefill': {
// /undo etc. — composer prefill lands with the composer-ref plumbing; show it for now.
const message = readStr(raw, 'message')
ctx.pushSystem(message ? `(edit & resubmit) ${message}` : `/${parsed.name}: nothing to prefill`)
return
}
default:
ctx.pushSystem(`error: invalid response: command.dispatch`)
}
}
/** Dispatch a `/command` through the ladder. Returns once the (async) work settles. */
export async function dispatchSlash(input: string, ctx: SlashContext): Promise<void> {
const parsed = parseSlash(input)
if (!parsed) return
const client = CLIENT[parsed.name]
if (client) {
await client(parsed.arg, ctx)
return
}
const sid = ctx.sessionId()
try {
const result = await ctx.request('slash.exec', { command: input.slice(1), session_id: sid })
const output = readStr(result, 'output') || `/${parsed.name}: no output`
const warning = readStr(result, 'warning')
ctx.pushSystem(warning ? `warning: ${warning}\n${output}` : output)
} catch {
try {
const raw = await ctx.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
handleDispatchResult(parsed, raw, ctx)
} catch (error) {
ctx.pushSystem(`error: ${error instanceof Error ? error.message : String(error)}`)
}
}
}

View file

@ -55,6 +55,8 @@ export type ActivePrompt =
| { kind: 'approval'; command: string; description: string }
| { kind: 'sudo'; requestId: string }
| { kind: 'secret'; envVar: string; prompt: string; requestId: string }
// local (non-gateway) Y/N confirm — e.g. /clear, /new (spec §2a)
| { kind: 'confirm'; message: string; onConfirm: () => void }
export interface StoreState {
ready: boolean
@ -152,6 +154,25 @@ export function createSessionStore() {
)
}
/** Push a system line (slash output, errors, notices). */
function pushSystem(text: string) {
setState(
produce(draft => {
draft.messages.push({ role: 'system', text })
})
)
}
/** Clear the transcript (e.g. /clear, /new). */
function clearTranscript() {
setState('messages', [])
}
/** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */
function setConfirm(message: string, onConfirm: () => void) {
setState('prompt', { kind: 'confirm', message, onConfirm })
}
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */
function apply(event: GatewayEvent): void {
if (buffering) {
@ -299,7 +320,17 @@ export function createSessionStore() {
for (const event of pending) applyNow(event)
}
return { state, apply, pushUser, hydrate, duplicate, clearPrompt } as const
return {
state,
apply,
pushUser,
pushSystem,
clearTranscript,
setConfirm,
hydrate,
duplicate,
clearPrompt
} as const
}
export type SessionStore = ReturnType<typeof createSessionStore>

View file

@ -0,0 +1,111 @@
/**
* Slash dispatch test (spec §5 Layer 3/4). Pure logic: parse + the dispatch
* ladder (client slash.exec command.dispatch) against a fake SlashContext.
*/
import { describe, expect, test } from 'bun:test'
import { dispatchSlash, parseSlash, type SlashContext } from '../logic/slash.ts'
describe('parseSlash', () => {
test('splits name + arg; rejects non-slash / empty', () => {
expect(parseSlash('/help')).toEqual({ name: 'help', arg: '' })
expect(parseSlash('/model anthropic/claude')).toEqual({ name: 'model', arg: 'anthropic/claude' })
expect(parseSlash('hello')).toBeNull()
expect(parseSlash('/')).toBeNull()
})
})
interface Probe {
ctx: SlashContext
calls: Array<{ method: string; params: Record<string, unknown> }>
system: string[]
submitted: string[]
confirmed: Array<{ message: string; onConfirm: () => void }>
quit: { value: boolean }
cleared: { value: boolean }
}
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
const calls: Probe['calls'] = []
const system: string[] = []
const submitted: string[] = []
const confirmed: Probe['confirmed'] = []
const quit = { value: false }
const cleared = { value: false }
const ctx: SlashContext = {
clearTranscript: () => (cleared.value = true),
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
pushSystem: text => system.push(text),
quit: () => (quit.value = true),
request: (method, params) => {
calls.push({ method, params })
return request(method, params)
},
sessionId: () => 'sid-1',
submit: text => submitted.push(text)
}
return { calls, cleared, confirmed, ctx, quit, submitted, system }
}
describe('dispatchSlash — client commands', () => {
test('/quit quits without hitting the gateway', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/quit', p.ctx)
expect(p.quit.value).toBe(true)
expect(p.calls).toHaveLength(0)
})
test('/clear opens a confirm; running onConfirm clears the transcript', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/clear', p.ctx)
expect(p.confirmed).toHaveLength(1)
expect(p.cleared.value).toBe(false)
p.confirmed[0]!.onConfirm()
expect(p.cleared.value).toBe(true)
})
test('/logs prints the recent ring lines', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/logs', p.ctx)
expect(p.system.join('\n')).toContain('session created')
})
test('/help renders the gateway catalog', async () => {
const p = makeCtx(async method =>
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}
)
await dispatchSlash('/help', p.ctx)
expect(p.calls[0]?.method).toBe('commands.catalog')
expect(p.system.join('\n')).toContain('/model — switch model')
})
})
describe('dispatchSlash — server ladder', () => {
test('unknown command → slash.exec; output shown as a system line', async () => {
const p = makeCtx(async method => (method === 'slash.exec' ? { output: 'all good' } : {}))
await dispatchSlash('/status', p.ctx)
expect(p.calls[0]).toEqual({ method: 'slash.exec', params: { command: 'status', session_id: 'sid-1' } })
expect(p.system).toContain('all good')
})
test('slash.exec rejects → command.dispatch; send result submits a user turn', async () => {
const p = makeCtx(async method => {
if (method === 'slash.exec') throw new Error('not a worker command')
if (method === 'command.dispatch') return { type: 'send', message: 'run the thing' }
return {}
})
await dispatchSlash('/dothing', p.ctx)
expect(p.calls.map(c => c.method)).toEqual(['slash.exec', 'command.dispatch'])
expect(p.submitted).toEqual(['run the thing'])
})
test('command.dispatch exec → system output', async () => {
const p = makeCtx(async method => {
if (method === 'slash.exec') throw new Error('reject')
return { type: 'exec', output: 'done' }
})
await dispatchSlash('/whatever', p.ctx)
expect(p.system).toContain('done')
})
})

View file

@ -0,0 +1,28 @@
/**
* 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 { useKeyboard } from '@opentui/solid'
import { useTheme } from '../theme.tsx'
export function ConfirmPrompt(props: { message: string; onYes: () => void; onNo: () => void }) {
const theme = useTheme()
useKeyboard(key => {
if (key.name === 'y' || key.name === 'return') props.onYes()
else if (key.name === 'n' || key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onNo()
})
return (
<box
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>
)
}

View file

@ -14,6 +14,7 @@ import { Match, Switch } from 'solid-js'
import type { 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 {
@ -24,9 +25,13 @@ export interface PromptOverlayProps {
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 = () => setTimeout(() => props.store.clearPrompt(), 0)
const respond = (method: string, params: Record<string, unknown>) => {
props.onRespond(method, params)
props.store.clearPrompt()
clearSoon()
}
const asApproval = () => {
@ -45,6 +50,10 @@ export function PromptOverlay(props: PromptOverlayProps) {
const p = prompt()
return p && p.kind === 'secret' ? p : undefined
}
const asConfirm = () => {
const p = prompt()
return p && p.kind === 'confirm' ? p : undefined
}
return (
<Switch>
@ -89,6 +98,18 @@ export function PromptOverlay(props: PromptOverlayProps) {
/>
)}
</Match>
<Match when={asConfirm()}>
{p => (
<ConfirmPrompt
message={p().message}
onYes={() => {
p().onConfirm()
clearSoon()
}}
onNo={clearSoon}
/>
)}
</Match>
</Switch>
)
}