Files
correx/docs/plans/2026-05-17-tui-refactor.progress.md
T
kami b267982005 refactor(tui): migrate renderer from Mosaic to tamboui
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.
2026-05-17 13:43:20 +04:00

9.5 KiB
Raw Blame History

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.gradle was bumped 2.0.21 → 2.2.10 globally.
  • Side-effect: 9 new MaxLineLength detekt failures in core/kernel/orchestration/{SessionOrchestrator,DefaultSessionOrchestrator}.kt — fixed by wrapping long System.err.println calls. Added LongMethod to existing @SuppressWarnings on SessionOrchestrator.
  • ./gradlew check green.

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 Filter to KeyEvent.kt.
  • Added JUnit 5 + kotlin-test deps + useJUnitPlatform() to apps/tui/build.gradle.

Task 1.3 — Replace stdin reader with Modifier.onKeyEvent

  • Deleted keyChannel, readKeys, charToKeyEvent, handleKeys, handleInputModeKey.
  • Created input/MosaicKeyMapper.kt mapping com.jakewharton.mosaic.layout.KeyEvent { key, alt, ctrl, shift } → domain KeyEvent.
  • Column now has Modifier.onKeyEvent { ... } at root; dispatch path = MosaicKeyMapper → KeyResolver → applyAction.
  • applyAction(action, state, wsClient, scope): TuiState is pure on state; ws sends launched via scope.launch from within (transitional — Task 2.3 replaces it).
  • TuiNavigation.kt shrunk: kept navigateUp/navigateDown, dropped parseEscapeSequence + handleCancel.

Task 1.4 — Steering note inside InputMode

  • InputMode final shape: None, WorkflowId, SteeringNote, Filter.
  • Action.OpenSteeringPrompt → switches to SteeringNote only if state.approval.active != null.
  • Action.SubmitInput with mode=SteeringNote → sends ApprovalResponse(decision=STEER, steeringNote=text), clears approval.
  • InputBar labels updated. handleSteer deleted. No System.console anywhere in apps/tui.

Task 2.1 — Split TuiState into slices

  • New: state/{ConnectionState,SessionsState,InputState,ApprovalState,ProviderState}.kt.
  • TuiState is now composition of the five slices with defaults.
  • Field mapping applied across TuiApp.kt, TuiNavigation.kt, StateReducer.kt, components/StatusBar.kt:
    • state.connected/reconnectingstate.connection.*
    • state.sessions (list) → state.sessions.sessions; state.selectedSessionIdstate.sessions.selectedId
    • state.activeApprovalstate.approval.active
    • state.inputText/inputModestate.input.text/mode
    • state.providerId/providerStatusstate.provider.id/status

Task 2.2 — Pure RootReducer + Effect

  • New: reducer/{Effect,InputReducer,SessionsReducer,ApprovalReducer,ConnectionReducer,ProviderReducer,ServerMessageReducer,RootReducer}.kt.
  • Action extended: 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 old applyAction/applyServerMessage paths still run.
  • Decisions deviating from plan: dropped unused selectedSessionId param on InputReducer.reduce; dropped unused clock param on ConnectionReducer.reduce. Old StateReducer.kt left in place alongside new ServerMessageReducer.kt (no callers of ServerMessageReducer yet).

Task 2.3 — EffectDispatcher + wire RootReducer into TuiApp

  • Create reducer/EffectDispatcher.kt.
  • Rewrite ws/TuiWsClient.kt: expose messages: SharedFlow<ServerMessage> and connection: SharedFlow<ConnectionEvent>; remove suspend-callback constructor params. Add sealed interface ConnectionEvent { Connected, Disconnected, RetryScheduled(attempt, nextRetryAtMs) }.
  • In TuiApp: single dispatch(action)RootReducer.reduceSnapshot.withMutableSnapshot { state = next } → trySend each Effect to a Channel<Effect>.
  • Launch coroutines: ws.connect(), collect ws.messagesdispatch(ServerEventReceived(it)), collect ws.connectiondispatch(it.toAction()), drain effects channel via EffectDispatcher.
  • Delete old applyAction/applyServerMessage (and StateReducer.kt if 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 TuiApp with Panel(title=…) { … }.
  • Drop every BOX_WIDTH constant and hand-padding from StatusBar, SessionList, ActiveSession, ApprovalPanel, InputBar.
  • grep -r "BOX_WIDTH" apps/tui must return nothing.

Task 3.3 — Responsive terminal width

  • Add readTerminalSize() (stty fallback, default 80×24) in TerminalSize.kt.
  • 500ms polling loop in TuiApp.runTuiApp; wrap content in CompositionLocalProvider(LocalTerminalSize provides …).

Task 4.2 — Session filter via InputMode.Filter

  • / enters Filter mode (already wired in resolver). On SubmitInput mode=Filter, copy text into sessions.filter. On CancelInput mode=Filter, clear filter.
  • SessionList renders only sessions whose workflowId contains filter (case-insensitive).

Task 4.3 — Reconnect feedback

  • In TuiWsClient.connect, before backoff delay, emit ConnectionEvent.RetryScheduled(attempt = ++count, nextRetryAtMs = clock() + delayMs).
  • ConnectionReducer.RetryScheduled already handles state; Connected resets attempt to 0.
  • StatusBar formats nextRetryAtMs - 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: on ServerEventReceived for InferenceCompleted/ToolCompleted/StageStarted/StageCompleted/StageFailed, append entry (cap 200).
  • Rename ActiveSession.ktSessionOutputLog.kt; render last N entries with scroll offset.
  • Add Action.ScrollLogUp/Down; introduce FocusState { SessionList, OutputLog }, Tab toggles. Map NavUp/NavDown to scroll when focus is on log.

Task 5.1 — Coverage gate

  • Add kover verify rule to apps/tui/build.gradle: minBound 70 on com.correx.apps.tui.reducer.* + com.correx.apps.tui.input.*. Confirm syntax against current kover version.

Task 5.2 — Final cleanup pass

  • Audit every @Suppress under apps/tui/src/main; either fix or document.
  • Update docs/refactor.md 2026-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 TerminalScreen buffer, GUI widgets (Panel, Window, Table, TextBox), borders, layouts, mouse. Imperative/Swing-style; bigger rewrite of TuiApp + 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 receivers warnings (-Xcontext-receivers) — future Kotlin will error on this.
  • Mosaic 0.18 KeyEvent lives at com.jakewharton.mosaic.layout.KeyEvent with fields key: String, alt, ctrl, shift. onKeyEvent is com.jakewharton.mosaic.layout.onKeyEvent.
  • All EventPayload subclasses must be registered in core/events/.../serialization/Serialization.kt — relevant for Task 4.1 if any new event types are added (none currently planned).