Replaces the Mosaic 0.18 string-frame renderer with tamboui 0.2.1-SNAPSHOT to unlock a cell buffer, widget catalog, and real layout engine. Domain core (state/reducer/ws/input) is unchanged; only the renderer and the key mapper are rewritten. Fixes a latent Mosaic-mapper bug where q/n/c/a/r/s// were swallowed inside input modes by making the new mapper mode-aware.
9.5 KiB
TUI Refactor — Progress Log
Plan: docs/plans/2026-05-17-tui-refactor.md
Last update: 2026-05-17
Mode: running with /implement (spec + quality review skipped per user instruction)
Base SHA at start: 58670a6 (no commits made yet — all changes still in working tree)
Completed
Task 1.1 — Upgrade Mosaic to 0.18.0 ✅
apps/tui/build.gradle: mosaic-runtime 0.13.0 → 0.18.0- Scope creep: Mosaic 0.18 requires Kotlin 2.2.10, so root
build.gradlewas bumped 2.0.21 → 2.2.10 globally. - Side-effect: 9 new
MaxLineLengthdetekt failures incore/kernel/orchestration/{SessionOrchestrator,DefaultSessionOrchestrator}.kt— fixed by wrapping longSystem.err.printlncalls. AddedLongMethodto existing@SuppressWarningsonSessionOrchestrator. ./gradlew checkgreen.
Task 1.2 — Action sealed type + KeyResolver ✅
- Created
apps/tui/.../input/Action.kt(14 variants),input/KeyResolver.kt,test/.../input/KeyResolverTest.kt(18 tests). - Added
FiltertoKeyEvent.kt. - Added JUnit 5 + kotlin-test deps +
useJUnitPlatform()toapps/tui/build.gradle.
Task 1.3 — Replace stdin reader with Modifier.onKeyEvent ✅
- Deleted
keyChannel,readKeys,charToKeyEvent,handleKeys,handleInputModeKey. - Created
input/MosaicKeyMapper.ktmappingcom.jakewharton.mosaic.layout.KeyEvent { key, alt, ctrl, shift }→ domainKeyEvent. Columnnow hasModifier.onKeyEvent { ... }at root; dispatch path = MosaicKeyMapper → KeyResolver →applyAction.applyAction(action, state, wsClient, scope): TuiStateis pure on state; ws sends launched viascope.launchfrom within (transitional — Task 2.3 replaces it).TuiNavigation.ktshrunk: keptnavigateUp/navigateDown, droppedparseEscapeSequence+handleCancel.
Task 1.4 — Steering note inside InputMode ✅
InputModefinal shape:None, WorkflowId, SteeringNote, Filter.Action.OpenSteeringPrompt→ switches toSteeringNoteonly ifstate.approval.active != null.Action.SubmitInputwith mode=SteeringNote → sendsApprovalResponse(decision=STEER, steeringNote=text), clears approval.InputBarlabels updated.handleSteerdeleted. NoSystem.consoleanywhere inapps/tui.
Task 2.1 — Split TuiState into slices ✅
- New:
state/{ConnectionState,SessionsState,InputState,ApprovalState,ProviderState}.kt. TuiStateis now composition of the five slices with defaults.- Field mapping applied across
TuiApp.kt,TuiNavigation.kt,StateReducer.kt,components/StatusBar.kt:state.connected/reconnecting→state.connection.*state.sessions(list) →state.sessions.sessions;state.selectedSessionId→state.sessions.selectedIdstate.activeApproval→state.approval.activestate.inputText/inputMode→state.input.text/modestate.providerId/providerStatus→state.provider.id/status
Task 2.2 — Pure RootReducer + Effect ✅
- New:
reducer/{Effect,InputReducer,SessionsReducer,ApprovalReducer,ConnectionReducer,ProviderReducer,ServerMessageReducer,RootReducer}.kt. Actionextended:ServerEventReceived(ServerMessage),Connected,Disconnected,RetryScheduled(attempt, nextRetryAtMs).- All slice reducers return
Pair<SliceState, List<Effect>>. Default arm =state to emptyList(). - 39 new tests added (55 total in module).
- NOT YET WIRED into
TuiApp.kt— that is Task 2.3. The oldapplyAction/applyServerMessagepaths still run. - Decisions deviating from plan: dropped unused
selectedSessionIdparam onInputReducer.reduce; dropped unusedclockparam onConnectionReducer.reduce. OldStateReducer.ktleft in place alongside newServerMessageReducer.kt(no callers ofServerMessageReduceryet).
Task 2.3 — EffectDispatcher + wire RootReducer into TuiApp ✅
- Create
reducer/EffectDispatcher.kt. - Rewrite
ws/TuiWsClient.kt: exposemessages: SharedFlow<ServerMessage>andconnection: SharedFlow<ConnectionEvent>; remove suspend-callback constructor params. Addsealed interface ConnectionEvent { Connected, Disconnected, RetryScheduled(attempt, nextRetryAtMs) }. - In
TuiApp: singledispatch(action)→RootReducer.reduce→Snapshot.withMutableSnapshot { state = next }→ trySend eachEffectto aChannel<Effect>. - Launch coroutines:
ws.connect(), collectws.messages→dispatch(ServerEventReceived(it)), collectws.connection→dispatch(it.toAction()), drain effects channel viaEffectDispatcher. - Delete old
applyAction/applyServerMessage(andStateReducer.ktif unused after the cut). - Smoke-test:
n→ workflow id prompt → submit → session starts; approval flow; steering note.
Task 3.1 — Panel composable + TerminalSize ✅
- Create
components/Panel.kt(@Composable fun Panel(title: String?, content: @Composable () -> Unit)). - Create
components/TerminalSize.kt(data class +staticCompositionLocalOf { TerminalSize(80, 24) }). - Do not wire yet.
Task 3.2 — Migrate components to Panel ✅
- Wrap each section in
TuiAppwithPanel(title=…) { … }. - Drop every
BOX_WIDTHconstant and hand-padding fromStatusBar,SessionList,ActiveSession,ApprovalPanel,InputBar. grep -r "BOX_WIDTH" apps/tuimust return nothing.
Task 3.3 — Responsive terminal width ✅
- Add
readTerminalSize()(stty fallback, default 80×24) inTerminalSize.kt. - 500ms polling loop in
TuiApp.runTuiApp; wrap content inCompositionLocalProvider(LocalTerminalSize provides …).
Task 4.2 — Session filter via InputMode.Filter ✅
/enters Filter mode (already wired in resolver). OnSubmitInputmode=Filter, copy text intosessions.filter. OnCancelInputmode=Filter, clear filter.SessionListrenders only sessions whoseworkflowIdcontainsfilter(case-insensitive).
Task 4.3 — Reconnect feedback ✅
- In
TuiWsClient.connect, before backoffdelay, emitConnectionEvent.RetryScheduled(attempt = ++count, nextRetryAtMs = clock() + delayMs). ConnectionReducer.RetryScheduledalready handles state;Connectedresets attempt to 0.StatusBarformatsnextRetryAtMs - clock()as countdown.
Pending — resume here
Task 4.1 — Scrollable session output log
- Extend
SessionsState:eventLog: Map<SessionId, ArrayDeque<LogEntry>>,scrollOffsetById: Map<SessionId, Int>. - New
LogEntry(timestampMs, kind, text). SessionsReducer: onServerEventReceivedfor InferenceCompleted/ToolCompleted/StageStarted/StageCompleted/StageFailed, append entry (cap 200).- Rename
ActiveSession.kt→SessionOutputLog.kt; render last N entries with scroll offset. - Add
Action.ScrollLogUp/Down; introduceFocusState { SessionList, OutputLog }, Tab toggles. MapNavUp/NavDownto scroll when focus is on log.
Task 5.1 — Coverage gate
- Add kover verify rule to
apps/tui/build.gradle: minBound 70 oncom.correx.apps.tui.reducer.*+com.correx.apps.tui.input.*. Confirm syntax against current kover version.
Task 5.2 — Final cleanup pass
- Audit every
@Suppressunderapps/tui/src/main; either fix or document. - Update
docs/refactor.md2026-05-17 entries to[done].
Sequencing
[done] 1.1 → 1.2 → 1.3 → 1.4 → 2.1 → 2.2 → 2.3 → 3.1 → 3.2 → 3.3 → {4.2, 4.3}
[next] 4.1 → 5.1 → 5.2
Renderer evaluation — Mosaic is the floor
Mosaic (jake-wharton/mosaic 0.18) is intentionally minimalist: re-emits plain text lines per frame, no screen-cell buffer, no widget catalog, no built-in borders/modals/scroll regions/mouse, no focus management. We hand-draw ─├┤ in components/Panel.kt. That is the entire framing system.
This is fine for a reducer/effect prototype but it caps how polished the TUI can ever look. Compared to Python's Textual we lack: cell-buffer rendering, layout engine, widget library (DataTable / Tree / Input / Modal), focus + mouse, scroll regions, transitions.
Candidate replacements to evaluate (renderer swap; reducer/Action/Effect core stays):
- tamboui — user-suggested: https://github.com/tamboui/tamboui (not investigated yet — no web access this session).
- Kotter — Compose-style DSL on JVM, more capable than Mosaic (real text-attribute model, animations, structured input). Closest semantic match to current code.
- Lanterna — Java TUI lib with real
TerminalScreenbuffer, GUI widgets (Panel,Window,Table,TextBox), borders, layouts, mouse. Imperative/Swing-style; bigger rewrite ofTuiApp+components/but reducers untouched. - Mordant — already transitive; only buys nicer ANSI paint, same ceiling as Mosaic.
Migration cost: ~all of apps/tui/src/main/.../TuiApp.kt and components/*. Unchanged: input/, reducer/, state/, ws/ — the renderer-agnostic core. That's the architectural payoff of Task 2.2/2.3.
Decision: deferred. Capture as standalone spec before swapping.
Notes for the next session
- Working tree currently has no commits since
58670a6; everything is staged-or-modified. Consider committing per task on resume. - Kotlin is now 2.2.10 repo-wide; expect occasional
experimental context receiverswarnings (-Xcontext-receivers) — future Kotlin will error on this. - Mosaic 0.18
KeyEventlives atcom.jakewharton.mosaic.layout.KeyEventwith fieldskey: String, alt, ctrl, shift.onKeyEventiscom.jakewharton.mosaic.layout.onKeyEvent. - All
EventPayloadsubclasses must be registered incore/events/.../serialization/Serialization.kt— relevant for Task 4.1 if any new event types are added (none currently planned).