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,23 +1,26 @@
import { useEffect, useRef } from 'react'
import { toolTrailLabel } from '../lib/text.js'
import type { ActiveTool, ActivityItem } from '../types.js'
import { LONG_RUN_CHARMS } from '../content/charms.js'
import { pick, toolTrailLabel } from '../lib/text.js'
import type { ActiveTool } from '../types.js'
import { turnController } from './turnController.js'
const DELAY_MS = 8_000
const INTERVAL_MS = 10_000
const MAX = 2
const CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…']
const MAX_CHARMS_PER_TOOL = 2
export function useLongRunToolCharms(
busy: boolean,
tools: ActiveTool[],
pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void
) {
const slotRef = useRef(new Map<string, { count: number; lastAt: number }>())
interface Slot {
count: number
lastAt: number
}
export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) {
const slots = useRef(new Map<string, Slot>())
useEffect(() => {
if (!busy || !tools.length) {
slotRef.current.clear()
slots.current.clear()
return
}
@ -26,9 +29,9 @@ export function useLongRunToolCharms(
const now = Date.now()
const liveIds = new Set(tools.map(t => t.id))
for (const key of [...slotRef.current.keys()]) {
for (const key of [...slots.current.keys()]) {
if (!liveIds.has(key)) {
slotRef.current.delete(key)
slots.current.delete(key)
}
}
@ -37,20 +40,17 @@ export function useLongRunToolCharms(
continue
}
const slot = slotRef.current.get(tool.id) ?? { count: 0, lastAt: 0 }
const slot = slots.current.get(tool.id) ?? { count: 0, lastAt: 0 }
if (slot.count >= MAX || now - slot.lastAt < INTERVAL_MS) {
if (slot.count >= MAX_CHARMS_PER_TOOL || now - slot.lastAt < INTERVAL_MS) {
continue
}
slot.count += 1
slot.lastAt = now
slotRef.current.set(tool.id, slot)
slots.current.set(tool.id, { count: slot.count + 1, lastAt: now })
const charm = CHARMS[Math.floor(Math.random() * CHARMS.length)]!
const sec = Math.round((now - tool.startedAt) / 1000)
pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`)
turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${sec}s)`)
}
}
@ -58,5 +58,5 @@ export function useLongRunToolCharms(
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [busy, pushActivity, tools])
}, [busy, tools])
}