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

@ -0,0 +1,185 @@
import { useCallback } from 'react'
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
import { ZERO } from '../domain/usage.js'
import { type GatewayClient } from '../gatewayClient.js'
import type { SessionCloseResponse, SessionCreateResponse, SessionResumeResponse } from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { Msg, SessionInfo, Usage } from '../types.js'
import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO)
const trimTail = (items: Msg[]) => {
const q = [...items]
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
q.pop()
}
if (q.at(-1)?.role === 'user') {
q.pop()
}
return q
}
export interface UseSessionLifecycleOptions {
colsRef: { current: number }
composerActions: ComposerActions
gw: GatewayClient
rpc: GatewayRpc
setHistoryItems: StateSetter<Msg[]>
setLastUserMsg: StateSetter<string>
setSessionStartedAt: StateSetter<number>
setStickyPrompt: StateSetter<string>
setVoiceProcessing: StateSetter<boolean>
setVoiceRecording: StateSetter<boolean>
sys: (text: string) => void
}
export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
const {
colsRef,
composerActions,
gw,
rpc,
setHistoryItems,
setLastUserMsg,
setSessionStartedAt,
setStickyPrompt,
setVoiceProcessing,
setVoiceRecording,
sys
} = opts
const closeSession = useCallback(
(targetSid?: null | string) =>
targetSid ? rpc<SessionCloseResponse>('session.close', { session_id: targetSid }) : Promise.resolve(null),
[rpc]
)
const resetSession = useCallback(() => {
turnController.fullReset()
setVoiceRecording(false)
setVoiceProcessing(false)
patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO })
setHistoryItems([])
setLastUserMsg('')
setStickyPrompt('')
composerActions.setPasteSnips([])
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
const resetVisibleHistory = useCallback(
(info: null | SessionInfo = null) => {
turnController.idle()
turnController.clearReasoning()
turnController.turnTools = []
turnController.persistedToolLabels.clear()
setHistoryItems(info ? [introMsg(info)] : [])
setStickyPrompt('')
setLastUserMsg('')
composerActions.setPasteSnips([])
patchTurnState({ activity: [] })
patchUiState({ info, usage: usageFrom(info) })
},
[composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt]
)
const newSession = useCallback(
async (msg?: string) => {
await closeSession(getUiState().sid)
const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current })
if (!r) {
return patchUiState({ status: 'ready' })
}
resetSession()
setSessionStartedAt(Date.now())
patchUiState({ info: r.info ?? null, sid: r.session_id, status: 'ready', usage: usageFrom(r.info ?? null) })
if (r.info) {
setHistoryItems([introMsg(r.info)])
}
if (r.info?.credential_warning) {
sys(`warning: ${r.info.credential_warning}`)
}
if (msg) {
sys(msg)
}
},
[closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
)
const resumeById = useCallback(
(id: string) => {
patchOverlayState({ picker: false })
patchUiState({ status: 'resuming…' })
closeSession(getUiState().sid === id ? null : getUiState().sid).then(() =>
gw
.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r) {
sys('error: invalid response: session.resume')
return patchUiState({ status: 'ready' })
}
resetSession()
setSessionStartedAt(Date.now())
const resumed = toTranscriptMessages(r.messages)
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
patchUiState({
info: r.info ?? null,
sid: r.session_id,
status: 'ready',
usage: usageFrom(r.info ?? null)
})
})
.catch((e: Error) => {
sys(`error: ${e.message}`)
patchUiState({ status: 'ready' })
})
)
},
[closeSession, colsRef, gw, resetSession, setHistoryItems, setSessionStartedAt, sys]
)
const guardBusySessionSwitch = useCallback(
(what = 'switch sessions') => {
if (!getUiState().busy) {
return false
}
sys(`interrupt the current turn before trying to ${what}`)
return true
},
[sys]
)
return {
closeSession,
guardBusySessionSwitch,
newSession,
resetSession,
resetVisibleHistory,
resumeById,
trimLastExchange: trimTail
}
}