Why
src/cli/ui/App.tsx is 3782 lines — 4.7× the project's 800-line
hard ceiling and the single largest architectural debt in the
codebase. It's dominated by one AppInner component (line 323)
holding 164 hook calls and ~20 pieces of top-level state.
This file blocks several other improvements:
Strategy
Staged extraction, every stage independently shippable. No big
bang. Each stage:
- Pulls one cohesive concern out of
AppInner (a custom hook or
a sub-component).
- Lands as one PR with green tests and no behavior change.
- Reduces App.tsx line count measurably; we update the tracking
table here as we go.
Per the project's autonomous-refactor pattern, once stages are
agreed we ship them back-to-back without pausing between PRs.
Stages
Each item below becomes a sub-PR (and may get its own issue if
discussion-worthy). Order is approximate — earlier ones unblock
later ones, but adjacent stages can land in either order.
Phase 1 — extract custom hooks (low risk)
State piles that already have a clear domain. These are the
cheapest wins.
Target after Phase 1: ~2500 lines.
Phase 2 — extract sub-components
The render output is a long JSX tree that mostly returns one big
column. Decompose by visual region.
Target after Phase 2: ~1500 lines.
Phase 3 — extract the slash-command surface
Slash command handlers are currently a wall of inline closures in
AppInner. Move them to a typed registry.
Target after Phase 3: ~800 lines.
Phase 4 — final polish
Non-goals
- Behavior changes. Each stage is mechanical refactor. Bug fixes
ride separate PRs.
- Renaming / restyling things. If a hook is called
useFoo today
the extracted hook stays useFoo. Stylistic swings expand the
diff and mask real changes.
- Test coverage growth as a goal. Add a test if extraction surfaces
a clean seam where one pays back; don't add tests just to bump
numbers (per CLAUDE.md).
- Cross-file API changes. We may expose hooks from new files,
but no caller outside cli/ui/ should need to learn new
imports.
Acceptance
Tracking
| Phase |
PR |
Status |
App.tsx LOC after |
| Baseline |
— |
— |
3782 |
| Phase 1 (hooks) |
TBD |
not started |
~2500 (target) |
| Phase 2 (components) |
TBD |
not started |
~1500 (target) |
| Phase 3 (slash) |
TBD |
not started |
~800 (target) |
| Phase 4 (polish) |
TBD |
not started |
≤500 (target) |
Tracking lives here. Update the table on every merge.
Relates to
Why
src/cli/ui/App.tsxis 3782 lines — 4.7× the project's 800-linehard ceiling and the single largest architectural debt in the
codebase. It's dominated by one
AppInnercomponent (line 323)holding 164 hook calls and ~20 pieces of top-level state.
This file blocks several other improvements:
React.memoon a 3782-line component doesnothing useful; meaningful memoization needs smaller subtrees.
The tick-driven re-render audit (Audit
useTickcallers and addReact.memoto tick-driven leaves — measure before optimizing #563) and thes.cardssubscriber audit (also in Audit
useTickcallers and addReact.memoto tick-driven leaves — measure before optimizing #563) can't reach their goals while theroot is this monolithic.
have to spin up the whole app.
the file that owns 20 pieces of orthogonal state.
Strategy
Staged extraction, every stage independently shippable. No big
bang. Each stage:
AppInner(a custom hook ora sub-component).
table here as we go.
Per the project's autonomous-refactor pattern, once stages are
agreed we ship them back-to-back without pausing between PRs.
Stages
Each item below becomes a sub-PR (and may get its own issue if
discussion-worthy). Order is approximate — earlier ones unblock
later ones, but adjacent stages can land in either order.
Phase 1 — extract custom hooks (low risk)
State piles that already have a clear domain. These are the
cheapest wins.
useTerminalSetup()— bracketed paste /modifyOtherKeys/ DECSET 1007 / cleanup on unmount (currently
App.tsx:398-426).useToolProgressDisplay()—ongoingTool+toolProgress+
statusLine+ the spinner-row state machine.useEditGate()—pendingEditsref +pendingCountmirror +
syncPendingCount+ interceptor wiring +editMode+ persistence.
useWorkspaceRoot()—currentRootDir+/cwdhandler+ propagation effects to memory / hooks / shell allowlist.
useHookList()—hookList+/hooks reload+ cwdrescan + loop sync.
usePresetMode()—preset+ persistence + thinkingmode + escalation arming.
useLanguageReload()—languageVersion+ i18n reloadeffect (currently a 2-line useState + useEffect that can
live next to its consumer).
Target after Phase 1: ~2500 lines.
Phase 2 — extract sub-components
The render output is a long JSX tree that mostly returns one big
column. Decompose by visual region.
<TerminalDressing>— DECSET-driven terminal escapebracketing (uses Phase 1's
useTerminalSetup).<HeaderBar>/<StatusBar>— already partly split asStatusRow; consolidate any remaining inline-renderedheader bits.
<MessageStream>(already exists asCardStream) — confirmno inline overrides leak from App.tsx.
<ComposerArea>— composer + hint + slash overlay (usesexisting
Composer+PromptInput; consolidate App.tsx-levelglue into the area).
<LiveActivityRow>— subagent + tool spinners + statusline, all the "what's happening right now" rows.
<UndoBanner>/<EditQueueBadge>— small, self-contained.Target after Phase 2: ~1500 lines.
Phase 3 — extract the slash-command surface
Slash command handlers are currently a wall of inline closures in
AppInner. Move them to a typed registry.
SlashHandlertype + a registry module.session commands / mcp commands / etc.).
moves to thin adapters that accept an
AppContextobject.src/cli/ui/slash/commands.ts(389 lines) is theprobable home — extend it rather than create a parallel
structure.
Target after Phase 3: ~800 lines.
Phase 4 — final polish
provider wiring, top-level hook composition, the render tree.
React.memowhere it now buys re-render isolation(Phase 2's components, not before — measuring matters).
useTickcallers and addReact.memoto tick-driven leaves — measure before optimizing #563 / Inflight cards occasionally fail to auto-close — deriverunningfrom an authoritative inflight set #557 / StreamingCard re-tokenizes full reply text on every chunk — estimate during streaming, exact only at done #562 audits with the new structurein place. Many will likely close themselves; the rest become
narrow targeted fixes.
Non-goals
ride separate PRs.
useFootodaythe extracted hook stays
useFoo. Stylistic swings expand thediff and mask real changes.
a clean seam where one pays back; don't add tests just to bump
numbers (per CLAUDE.md).
but no caller outside
cli/ui/should need to learn newimports.
Acceptance
src/cli/ui/hooks/or new component files exceeds400 lines (the soft target — small files preferred over one
"less monstrous" replacement monolith).
useTickcallers and addReact.memoto tick-driven leaves — measure before optimizing #563 are unblocked:React.memo/
<Static>coverage can be reasoned about per-component.Tracking
Tracking lives here. Update the table on every merge.
Relates to
useTickcallers and addReact.memoto tick-driven leaves — measure before optimizing #563 —useTick+s.cardsre-render audit (will be unblockedafter Phase 1-2)
runningfrom an authoritative inflight set #557 — deriverunningstatus from inflight set (orthogonal butbenefits from cleaner state ownership)
render-budget family)