From 87634e19fd108c7cf517943df36c054a9e26d8bd Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 15:20:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(opentui-v2):=20Phase=204a=20=E2=80=94=20sl?= =?UTF-8?q?ash=20command=20system=20+=20local=20confirm=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui-opentui-v2/src/entry/main.tsx | 32 +++- ui-tui-opentui-v2/src/logic/slash.ts | 154 ++++++++++++++++++ ui-tui-opentui-v2/src/logic/store.ts | 33 +++- ui-tui-opentui-v2/src/test/slash.test.ts | 111 +++++++++++++ .../src/view/prompts/confirmPrompt.tsx | 28 ++++ .../src/view/prompts/promptOverlay.tsx | 23 ++- 6 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 ui-tui-opentui-v2/src/logic/slash.ts create mode 100644 ui-tui-opentui-v2/src/test/slash.test.ts create mode 100644 ui-tui-opentui-v2/src/view/prompts/confirmPrompt.tsx diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index 2eeaa1d5d6d..8e0310e6a16 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -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) => { diff --git a/ui-tui-opentui-v2/src/logic/slash.ts b/ui-tui-opentui-v2/src/logic/slash.ts new file mode 100644 index 00000000000..b8e3309d566 --- /dev/null +++ b/ui-tui-opentui-v2/src/logic/slash.ts @@ -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) => Promise + 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 + +/** The TUI-only client commands (run in-process, never hit the gateway). */ +const CLIENT: Record = { + 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 { + 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)}`) + } + } +} diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index f11ee414659..b2341153ba2 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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 diff --git a/ui-tui-opentui-v2/src/test/slash.test.ts b/ui-tui-opentui-v2/src/test/slash.test.ts new file mode 100644 index 00000000000..586dcc39f69 --- /dev/null +++ b/ui-tui-opentui-v2/src/test/slash.test.ts @@ -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 }> + system: string[] + submitted: string[] + confirmed: Array<{ message: string; onConfirm: () => void }> + quit: { value: boolean } + cleared: { value: boolean } +} + +function makeCtx(request: (method: string, params: Record) => Promise): 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') + }) +}) diff --git a/ui-tui-opentui-v2/src/view/prompts/confirmPrompt.tsx b/ui-tui-opentui-v2/src/view/prompts/confirmPrompt.tsx new file mode 100644 index 00000000000..94d02d3a7d1 --- /dev/null +++ b/ui-tui-opentui-v2/src/view/prompts/confirmPrompt.tsx @@ -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 ( + + + {props.message} + + y/Enter confirm · n/Esc cancel + + ) +} diff --git a/ui-tui-opentui-v2/src/view/prompts/promptOverlay.tsx b/ui-tui-opentui-v2/src/view/prompts/promptOverlay.tsx index 7dfe37d2725..fe6b3744139 100644 --- a/ui-tui-opentui-v2/src/view/prompts/promptOverlay.tsx +++ b/ui-tui-opentui-v2/src/view/prompts/promptOverlay.tsx @@ -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) => { 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 ( @@ -89,6 +98,18 @@ export function PromptOverlay(props: PromptOverlayProps) { /> )} + + {p => ( + { + p().onConfirm() + clearSoon() + }} + onNo={clearSoon} + /> + )} + ) }