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

@ -36,7 +36,7 @@ export interface CompletionItem {
}
export interface GatewayRpc {
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<T | null>
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<null | T>
}
export interface GatewayServices {
@ -53,10 +53,10 @@ export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
modelPicker: boolean
pager: PagerState | null
pager: null | PagerState
picker: boolean
secret: SecretReq | null
sudo: SudoReq | null
secret: null | SecretReq
sudo: null | SudoReq
}
export interface PagerState {
@ -65,11 +65,6 @@ export interface PagerState {
title?: string
}
export interface ToolCompleteRibbon {
label: string
line: string
}
export interface TranscriptRow {
index: number
key: string
@ -81,8 +76,8 @@ export interface UiState {
busy: boolean
compact: boolean
detailsMode: DetailsMode
info: SessionInfo | null
sid: string | null
info: null | SessionInfo
sid: null | string
status: string
statusBar: boolean
theme: Theme
@ -112,18 +107,18 @@ export interface ComposerActions {
pushHistory: (text: string) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
setHistoryIdx: StateSetter<number | null>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setPasteSnips: StateSetter<PasteSnippet[]>
setQueueEdit: (index: number | null) => void
setQueueEdit: (index: null | number) => void
syncQueue: () => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<number | null>
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
}
@ -132,11 +127,11 @@ export interface ComposerState {
compIdx: number
compReplace: number
completions: CompletionItem[]
historyIdx: number | null
historyIdx: null | number
input: string
inputBuf: string[]
pasteSnips: PasteSnippet[]
queueEditIdx: number | null
queueEditIdx: null | number
queuedDisplay: string[]
}
@ -152,72 +147,6 @@ export interface UseComposerStateResult {
state: ComposerState
}
export interface InterruptTurnOptions {
appendMessage: (msg: Msg) => void
gw: { request: (method: string, params?: Record<string, unknown>) => Promise<unknown> }
sid: string
sys: (text: string) => void
}
export interface TurnActions {
clearReasoning: () => void
endReasoningPhase: () => void
idle: () => void
interruptTurn: (options: InterruptTurnOptions) => void
pruneTransient: () => void
pulseReasoningStreaming: () => void
pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void
pushTrail: (line: string) => void
scheduleReasoning: () => void
scheduleStreaming: () => void
setActivity: StateSetter<ActivityItem[]>
setReasoning: StateSetter<string>
setReasoningTokens: StateSetter<number>
setReasoningActive: StateSetter<boolean>
setToolTokens: StateSetter<number>
setReasoningStreaming: StateSetter<boolean>
setStreaming: StateSetter<string>
setSubagents: StateSetter<SubagentProgress[]>
setTools: StateSetter<ActiveTool[]>
setTurnTrail: StateSetter<string[]>
}
export interface TurnRefs {
activeToolsRef: MutableRefObject<ActiveTool[]>
bufRef: MutableRefObject<string>
interruptedRef: MutableRefObject<boolean>
lastStatusNoteRef: MutableRefObject<string>
persistedToolLabelsRef: MutableRefObject<Set<string>>
protocolWarnedRef: MutableRefObject<boolean>
reasoningRef: MutableRefObject<string>
reasoningStreamingTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
reasoningTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
statusTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
streamTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
toolTokenAccRef: MutableRefObject<number>
toolCompleteRibbonRef: MutableRefObject<ToolCompleteRibbon | null>
turnToolsRef: MutableRefObject<string[]>
}
export interface TurnState {
activity: ActivityItem[]
reasoning: string
reasoningTokens: number
reasoningActive: boolean
reasoningStreaming: boolean
streaming: string
subagents: SubagentProgress[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
}
export interface UseTurnStateResult {
actions: TurnActions
refs: TurnRefs
state: TurnState
}
export interface InputHandlerActions {
answerClarify: (answer: string) => void
appendMessage: (msg: Msg) => void
@ -238,15 +167,11 @@ export interface InputHandlerContext {
gateway: GatewayServices
terminal: {
hasSelection: boolean
scrollRef: RefObject<ScrollBoxHandle | null>
scrollRef: RefObject<null | ScrollBoxHandle>
scrollWithSelection: (delta: number) => void
selection: SelectionApi
stdout?: NodeJS.WriteStream
}
turn: {
actions: TurnActions
refs: TurnRefs
}
voice: {
recording: boolean
setProcessing: StateSetter<boolean>
@ -262,7 +187,7 @@ export interface InputHandlerResult {
export interface GatewayEventHandlerContext {
composer: {
dequeue: () => string | undefined
queueEditRef: MutableRefObject<number | null>
queueEditRef: MutableRefObject<null | number>
sendQueued: (text: string) => void
}
gateway: GatewayServices
@ -271,7 +196,7 @@ export interface GatewayEventHandlerContext {
colsRef: MutableRefObject<number>
newSession: (msg?: string) => void
resetSession: () => void
setCatalog: StateSetter<SlashCatalog | null>
setCatalog: StateSetter<null | SlashCatalog>
}
system: {
bellOnComplete: boolean
@ -282,45 +207,9 @@ export interface GatewayEventHandlerContext {
appendMessage: (msg: Msg) => void
setHistoryItems: StateSetter<Msg[]>
}
turn: {
actions: Pick<
TurnActions,
| 'clearReasoning'
| 'endReasoningPhase'
| 'idle'
| 'pruneTransient'
| 'pulseReasoningStreaming'
| 'pushActivity'
| 'pushTrail'
| 'scheduleReasoning'
| 'scheduleStreaming'
| 'setActivity'
| 'setReasoningTokens'
| 'setStreaming'
| 'setSubagents'
| 'setToolTokens'
| 'setTools'
| 'setTurnTrail'
>
refs: Pick<
TurnRefs,
| 'activeToolsRef'
| 'bufRef'
| 'interruptedRef'
| 'lastStatusNoteRef'
| 'persistedToolLabelsRef'
| 'protocolWarnedRef'
| 'reasoningRef'
| 'statusTimerRef'
| 'toolTokenAccRef'
| 'toolCompleteRibbonRef'
| 'turnToolsRef'
>
}
}
export interface SlashHandlerContext {
slashFlightRef: MutableRefObject<number>
composer: {
enqueue: (text: string) => void
hasSelection: boolean
@ -331,20 +220,21 @@ export interface SlashHandlerContext {
}
gateway: GatewayServices
local: {
catalog: SlashCatalog | null
catalog: null | SlashCatalog
getHistoryItems: () => Msg[]
getLastUserMsg: () => string
maybeWarn: (value: any) => void
maybeWarn: (value: unknown) => void
}
session: {
closeSession: (targetSid?: string | null) => Promise<unknown>
closeSession: (targetSid?: null | string) => Promise<unknown>
die: () => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
resetVisibleHistory: (info?: SessionInfo | null) => void
resetVisibleHistory: (info?: null | SessionInfo) => void
resumeById: (id: string) => void
setSessionStartedAt: StateSetter<number>
}
slashFlightRef: MutableRefObject<number>
transcript: {
page: (text: string, title?: string) => void
panel: (title: string, sections: PanelSection[]) => void
@ -377,7 +267,7 @@ export interface AppLayoutComposerProps {
input: string
inputBuf: string[]
pagerPageSize: number
queueEditIdx: number | null
queueEditIdx: null | number
queuedDisplay: string[]
submit: (value: string) => void
updateInput: StateSetter<string>
@ -386,9 +276,9 @@ export interface AppLayoutComposerProps {
export interface AppLayoutProgressProps {
activity: ActivityItem[]
reasoning: string
reasoningTokens: number
reasoningActive: boolean
reasoningStreaming: boolean
reasoningTokens: number
showProgressArea: boolean
showStreamingArea: boolean
streaming: string
@ -401,7 +291,7 @@ export interface AppLayoutProgressProps {
export interface AppLayoutStatusProps {
cwdLabel: string
goodVibesTick: number
sessionStartedAt: number | null
sessionStartedAt: null | number
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string
@ -410,7 +300,7 @@ export interface AppLayoutStatusProps {
export interface AppLayoutTranscriptProps {
historyItems: Msg[]
scrollRef: RefObject<ScrollBoxHandle | null>
scrollRef: RefObject<null | ScrollBoxHandle>
virtualHistory: VirtualHistoryState
virtualRows: TranscriptRow[]
}