refactor(tui): store-driven turn state + slash registry + module split

Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.

Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.

Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).

Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.

Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.

Tests: 50 passing. Build + type-check clean.
This commit is contained in:
Brooklyn Nicholson 2026-04-16 12:18:56 -05:00
parent 9c71f3a6ea
commit 68ecdb6e26
56 changed files with 3666 additions and 4117 deletions

View file

@ -1,12 +1,11 @@
import { parseSlashCommand } from '../domain/slash.js'
import type { SlashExecResponse } from '../gatewayTypes.js'
import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js'
import type { SlashHandlerContext } from './interfaces.js'
import { createSlashCoreHandler } from './slash/createSlashCoreHandler.js'
import { createSlashOpsHandler } from './slash/createSlashOpsHandler.js'
import { createSlashSessionHandler } from './slash/createSlashSessionHandler.js'
import { isStaleSlash } from './slash/isStaleSlash.js'
import { createSlashShared, parseSlashCommand } from './slash/shared.js'
import { findSlashCommand } from './slash/registry.js'
import { createSlashShared } from './slash/shared.js'
import type { SlashRunCtx } from './slash/types.js'
import { getUiState } from './uiStore.js'
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
@ -14,18 +13,37 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
const { catalog } = ctx.local
const { send, sys } = ctx.transcript
const shared = createSlashShared({ ...ctx.transcript, gw, slashFlightRef: ctx.slashFlightRef })
const handleCore = createSlashCoreHandler(ctx)
const handleSession = createSlashSessionHandler(ctx, shared)
const handleOps = createSlashOpsHandler(ctx)
const handler = (cmd: string): boolean => {
const flight = ++ctx.slashFlightRef.current
const ui = getUiState()
const sidAtSend = ui.sid
const parsed = { ...parseSlashCommand(cmd), flight, sid: sidAtSend, ui }
const sid = ui.sid
const parsed = parseSlashCommand(cmd)
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
if (handleCore(parsed) || handleSession(parsed) || handleOps(parsed)) {
const stale = () => flight !== ctx.slashFlightRef.current || getUiState().sid !== sid
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T): void => {
if (!stale() && r) {
fn(r)
}
}
const guardedErr = (e: unknown) => {
if (!stale()) {
sys(`error: ${rpcErrorMessage(e)}`)
}
}
const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, shared, sid, stale, ui }
const found = findSlashCommand(parsed.name)
if (found) {
found.run(parsed.arg, runCtx, cmd)
return true
}
@ -51,9 +69,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
}
}
gw.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: sidAtSend })
gw.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: sid })
.then(r => {
if (isStaleSlash(ctx, flight, sidAtSend)) {
if (stale()) {
return
}
@ -64,41 +82,33 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
)
})
.catch(() => {
gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: sidAtSend })
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
.then((raw: unknown) => {
if (isStaleSlash(ctx, flight, sidAtSend)) {
if (stale()) {
return
}
const d = asCommandDispatch(raw)
if (!d) {
sys('error: invalid response: command.dispatch')
return
return sys('error: invalid response: command.dispatch')
}
if (d.type === 'exec' || d.type === 'plugin') {
sys(d.output || '(no output)')
} else if (d.type === 'alias') {
handler(`/${d.target}${argTail}`)
} else if (d.type === 'skill') {
return sys(d.output || '(no output)')
}
if (d.type === 'alias') {
return handler(`/${d.target}${argTail}`)
}
if (d.type === 'skill') {
sys(`⚡ loading skill: ${d.name}`)
if (typeof d.message === 'string' && d.message.trim()) {
send(d.message)
} else {
sys(`/${parsed.name}: skill payload missing message`)
}
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`)
}
})
.catch((e: unknown) => {
if (isStaleSlash(ctx, flight, sidAtSend)) {
return
}
sys(`error: ${rpcErrorMessage(e)}`)
})
.catch(guardedErr)
})
return true