# Tamboui TUI Migration - **Status:** draft - **Owner:** kami - **Date:** 2026-05-17 - **Relates to:** `docs/plans/2026-05-17-tui-refactor.md`, `docs/plans/2026-05-17-tui-refactor.progress.md` - **Supersedes:** Mosaic-based renderer in `apps/tui` ## Motivation The current TUI renderer is built on Mosaic 0.18. Mosaic is a Compose-for-terminal runtime that has carried us through the initial event-sourced TUI but caps the level of polish we can ship: - No cell buffer — every frame is a string we hand-assemble. - No widget catalog (no list, paragraph, table, scrollbar). - No focus or mouse infrastructure. - No scroll regions or viewport math. - No real layout engine — we hand-draw borders and pad rows. The recent TUI refactor (Tasks 2.1–2.3 in the progress log) deliberately split the app into renderer-agnostic subsystems: `input/`, `reducer/`, `state/`, and `ws/` no longer depend on Mosaic. The renderer surface is now small enough to swap. A spike on a feature branch (now folded into `master`) proved that tamboui hosts the existing `RootReducer` and `TuiState` without changes. The working spike lives at `apps/tui/src/main/kotlin/com/correx/apps/tui/spike/SpikeMain.kt` (~218 lines) and is the canonical reference for the migration. ## Goals - Replace Mosaic with tamboui as the renderer for `apps/tui`. - Preserve current UX: status bar, session list with filter, approval flow, steering input, reconnect countdown. - Keep `state/`, `reducer/`, `ws/`, and `input/` (minus the Mosaic key mapper) untouched. The renderer-agnostic core stays renderer-agnostic. - Land the migration on the current `0.2.1-SNAPSHOT` line of tamboui without forking. ## Non-Goals - No changes to the WebSocket protocol or `TuiWsClient` flows. - No reducer or state-shape changes. - No new features. The scrollable log work in Task 4.1 of the progress log stays its own task and should be redone on top of tamboui rather than ported. - No fork of tamboui to extend its API. Fork-to-pin is the only sanctioned escape hatch if upstream snapshot churn breaks us. - No mouse, no theming engine, no devtools, no snapshot tests for widgets. ## Library Facts (pin these) ### Gradle coordinates (Groovy DSL) ```groovy repositories { maven { url 'https://central.sonatype.com/repository/maven-snapshots/' mavenContent { snapshotsOnly() } } } configurations.all { resolutionStrategy.cacheChangingModulesFor 0, 'seconds' } dependencies { implementation platform('dev.tamboui:tamboui-bom:0.2.1-SNAPSHOT') implementation 'dev.tamboui:tamboui-tui' implementation 'dev.tamboui:tamboui-widgets' implementation 'dev.tamboui:tamboui-core' runtimeOnly 'dev.tamboui:tamboui-jline3-backend' } ``` ### Maturity - License: MIT. - Distribution: snapshot-only. - API stability: experimental, "APIs may/will change." - Bytecode: Java 8. Multi-release jar; `META-INF/versions/11` is module-info only. - JDK 21 (our toolchain) is fine. ### Stability strategy Stay on `0.2.1-SNAPSHOT`. If upstream breaks us, **fork-to-pin** (clone the upstream repo at the known-good commit, `publishToMavenLocal`, swap coordinates). Do **not** fork-to-extend — tamboui is a TUI framework project, not part of correx's scope. ## Domain Reference - **KeyEvent** (`apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt`): sealed class with variants - `object Quit` - `object NewSession` - `object Cancel` - `object Approve` - `object Reject` - `object Steer` - `object NavUp` - `object NavDown` - `object Filter` - `object Enter` - `object Backspace` - `object Escape` - `data class CharInput(val ch: Char)` - **Action** (`apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt`): sealed interface with variants - `data object Quit` - `data object OpenNewSessionPrompt` - `data object CancelSelectedSession` - `data object ApproveActive` - `data object RejectActive` - `data object OpenSteeringPrompt` - `data object NavigateUp` - `data object NavigateDown` - `data object OpenFilter` - `data class AppendChar(val ch: Char)` - `data object Backspace` - `data object SubmitInput` - `data object CancelInput` - `data class ServerEventReceived(val message: ServerMessage)` - `data object Connected` - `data object Disconnected` - `data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long)` - **InputMode** (`apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt`): enum with variants `None`, `WorkflowId`, `SteeringNote`, `Filter` - **InputState**: `data class InputState(val mode: InputMode = InputMode.None, val text: String = "")` - **TuiState** (`apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt`): composition of slices - `connection: ConnectionState` - `sessions: SessionsState` - `input: InputState` - `approval: ApprovalState` - `provider: ProviderState` - **SessionsState**: `data class SessionsState(val sessions: List = emptyList(), val selectedId: String? = null, val filter: String = "")` - **ConnectionState**: `data class ConnectionState(val connected: Boolean = false, val reconnecting: Boolean = false, val attempt: Int = 0, val nextRetryAtMs: Long? = null)` - **ProviderState**: `data class ProviderState(val id: String = "", val status: String = "unknown")` - **ApprovalState**: `data class ApprovalState(val active: ApprovalInfo? = null)` - **Effect** (`apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt`): sealed interface with variants - `data class SendWs(val message: ClientMessage)` - `data object Quit` - **EffectDispatcher** (`apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt`): - `suspend fun dispatch(effect: Effect)` — takes a single effect and handles it (SendWs via wsClient, Quit via onQuit callback). - **RootReducer** (`apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt`): - `fun reduce(state: TuiState, action: Action, clock: () -> Long = System::currentTimeMillis): Pair>` - **TuiWsClient** (`apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt`): - `val messages: SharedFlow` — emits server messages from the WebSocket stream. - `val connection: SharedFlow` — emits connection state changes. - **ConnectionEvent** sealed interface variants: - `data object Connected` - `data object Disconnected` - `data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long)` ## Verified Tamboui API These are the only APIs the migration needs. Implementer agents should not `javap` jars or browse upstream; treat this section as authoritative. ### Runner - `dev.tamboui.tui.TuiRunner` — `TuiRunner.create()` returns an `AutoCloseable`. - Use `TuiRunner.create().use { runner -> runner.run(handler, renderer) }`. - Defaults come from `TuiConfig.defaults()`: rawMode=true, alternateScreen=true, hideCursor=true, mouseCapture=false, pollTimeout=40ms, tickRate=40ms, shutdownHook=true. - `runner.quit()` exits the event loop. - `runner.runOnRenderThread(Runnable)` / `runner.runLater(Runnable)` post work from other threads onto the render thread. ### Event handler and renderer SAMs - `dev.tamboui.tui.EventHandler` — `boolean handle(Event, TuiRunner)`. Return `true` to consume. - `dev.tamboui.tui.Renderer` — `void render(Frame)`. - Both callbacks run on the single render thread; a plain `var state` field is thread-safe as long as nothing else mutates it. ### Events - `dev.tamboui.tui.event.Event` (sealed) with variants `KeyEvent`, `MouseEvent`, `ResizeEvent`, `TickEvent`. - `KeyEvent` API: `code(): KeyCode`, `character(): Char`, `hasCtrl()`, `hasAlt()`, `hasShift()`, `isCtrlC()`, `isKey(KeyCode)`, `isCharIgnoreCase(Char)`. - `KeyCode` enum: `UP, DOWN, LEFT, RIGHT, ENTER, ESCAPE, BACKSPACE, TAB, CHAR, ...`. ### Frame and layout - `dev.tamboui.terminal.Frame` (not `dev.tamboui.tui.Frame`). - `area(): Rect`, `width(): int`, `height(): int`. - `renderWidget(Widget, Rect)`. - ` renderStatefulWidget(StatefulWidget, Rect, S)`. - `dev.tamboui.layout.Rect(x, y, w, h)`; `x()/y()/width()/height()` are methods. - `dev.tamboui.layout.Layout` builder: `Layout.vertical()/horizontal().constraints(Constraint.length(n), Constraint.fill(), Constraint.ratio(a,b)).spacing(n).split(area)` returns `List`. Manual `Rect` arithmetic is fine for trivial splits. ### Widgets - `dev.tamboui.widgets.block.Block` — `Block.builder().title(String|Title) .titleBottom(...).borders(Borders.ALL).borderType(BorderType.ROUNDED|PLAIN|DOUBLE|THICK) .style(Style).borderStyle(Style).borderColor(Color).build()`. **Block has no `.widget(...)` method.** Other widgets attach a Block via *their* `.block(...)`. - `dev.tamboui.widgets.paragraph.Paragraph` — `Paragraph.from(String|Line|Text)` factory and `Paragraph.builder().text(String|Line|Text).style(Style).left()/center()/right() .block(Block).foreground(Color).background(Color).build()`. - `dev.tamboui.widgets.list.ListWidget` — `StatefulWidget`. Builder: `.items(vararg String | vararg ListItem | List)`, `.block(Block)`, `.style(Style)` (unselected), `.highlightStyle(Style)` (selected), `.highlightSymbol(String|Line)`, `.scrollbarThumbStyle(Style)`, `.scrollbarTrackStyle(Style)`, `.itemStyleResolver(BiFunction)`. - `dev.tamboui.widgets.list.ListState` — *mutable*. `select(Integer): void`, `selectNext(int total)`, `selectPrevious()`. Create once, mutate per frame. - `dev.tamboui.widgets.list.ListItem.from(Line)` builds a styled list row. ### Styled text - `dev.tamboui.text.Span` — `Span.styled(String, Style)`, `Span.raw(String)`. Chainable: `.bold()`, `.italic()`, `.fg(Color)`, named-color shortcuts `.red()/.green()/.cyan()/...`. - `dev.tamboui.text.Line` — `Line.from(vararg Span | List)`, `Line.styled(String, Style)`. Chainable: `.bold()`, `.fg(Color)`, `.alignment(LEFT|CENTER|RIGHT)`, `.append(Span)`. - `dev.tamboui.text.Text.from(Line)` wraps a `Line` for `Paragraph.Builder.text(Text)`. ### Style and Color - `dev.tamboui.style.Style.create()` builder. Modifiers: `.bold()/.dim()/.italic() /.underlined()/.slowBlink()/.rapidBlink()/.reversed()/.hidden()/.crossedOut()` and their `.notXxx()` negations. Colors: `.fg(Color)/.bg(Color)/.underlineColor(Color)`. Named shortcuts: `.black()/.red()/.green()/.yellow()/.blue()/.magenta()/.cyan() /.white()/.gray()`. Background shortcuts: `.onRed()/.onGreen()/...`. Constant: `Style.EMPTY`. - `dev.tamboui.style.Color` — constants `BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, GRAY, DARK_GRAY, LIGHT_RED, LIGHT_GREEN, LIGHT_YELLOW, LIGHT_BLUE, LIGHT_MAGENTA, LIGHT_CYAN, BRIGHT_WHITE, RESET`. Factories: `Color.rgb(r,g,b)`, `Color.hex("#RRGGBB")`, `Color.indexed(0..255)`. - `dev.tamboui.style.Modifier` enum: `NORMAL, BOLD, DIM, ITALIC, UNDERLINED, SLOW_BLINK, RAPID_BLINK, REVERSED, HIDDEN, CROSSED_OUT`. ## Gradle Launch — Critical **`./gradlew run` and any `JavaExec`-backed task are unsupported.** The Gradle daemon has no TTY, JLine falls back to a dumb terminal, escape sequences leak, and rendering breaks. The supported pattern is `installDist` + the generated bin script in a real terminal. Current wiring in `apps/tui/build.gradle` already follows this pattern for the spike; keep it but rename the launcher from `spike` to `tui`: ```groovy tasks.register('tuiStartScripts', CreateStartScripts) { mainClass = 'com.correx.apps.tui.TuiAppKt' applicationName = 'tui' outputDir = file("$buildDir/scripts-tui") classpath = tasks.named('startScripts').get().classpath } distributions.named('main').configure { contents { from(tasks.named('tuiStartScripts')) { into 'bin' } } } ``` Launch: `./gradlew :apps:tui:installDist && ./apps/tui/build/install/tui/bin/tui`. ## Integration Patterns (proven by the spike) 1. **Key mapping.** Translate tamboui `KeyEvent` into the domain `KeyEvent` (`com.correx.apps.tui.KeyEvent`, not under `input/`). The mapper is **mode-aware**: in `InputMode.None`, character keys map to semantic intents (`'q' → Quit`, `'n' → NewSession`, etc.); in every other mode, character keys map to `CharInput(ch)` so they reach the input buffer. This fixes a latent Mosaic bug (`MosaicKeyMapper` is unconditional and silently swallows `q/n/c/a/r/s/'/'` in input modes). The migration must preserve the mode-aware shape and the existing Mosaic mapper should be deleted, not ported verbatim. ```kotlin private fun mapKey(e: TambouiKeyEvent, mode: InputMode): DomainKeyEvent? { if (e.isCtrlC()) return DomainKeyEvent.Quit if (e.hasCtrl() || e.hasAlt()) return null return when (e.code()) { KeyCode.UP -> DomainKeyEvent.NavUp KeyCode.DOWN -> DomainKeyEvent.NavDown KeyCode.ENTER -> DomainKeyEvent.Enter KeyCode.ESCAPE -> DomainKeyEvent.Escape KeyCode.BACKSPACE -> DomainKeyEvent.Backspace KeyCode.TAB -> DomainKeyEvent.Filter KeyCode.CHAR -> { val ch = e.character() if (ch.isISOControl()) return null if (mode != InputMode.None) return DomainKeyEvent.CharInput(ch) when (ch) { 'q' -> DomainKeyEvent.Quit 'n' -> DomainKeyEvent.NewSession 'c' -> DomainKeyEvent.Cancel 'a' -> DomainKeyEvent.Approve 'r' -> DomainKeyEvent.Reject 's' -> DomainKeyEvent.Steer '/' -> DomainKeyEvent.Filter else -> DomainKeyEvent.CharInput(ch) } } else -> null } } ``` 2. **Dispatch loop.** `dispatch(action)` calls `RootReducer.reduce(state, action)`, assigns the returned state, and discards effects for now. Real effect plumbing is its own task. 3. **List state sync.** Each frame, compute the visual index of `state.sessions.selectedId` within the filtered list and call `listState.select(idx)` before `renderStatefulWidget`. Domain state owns selection; `ListState` is a view detail. 4. **Layout shape.** `[status 1 row][sessions fill][input 1 row when mode != None] [help 1 row dim]`. 5. **KeyResolver signature.** `KeyResolver.resolve(key, mode, inputText)` takes three arguments; `inputText` is required because `SubmitInput` is suppressed when the input is blank. See `apps/tui/src/main/kotlin/com/correx/apps/tui/spike/SpikeMain.kt` for a working end-to-end example. ## Scope ### Files rewritten - `apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt` - `apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt` - `apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt` - `apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt` - `apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt` - `apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt` ### Files deleted - `apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt` — tamboui `Block` replaces it. - `apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt` — tamboui owns terminal dimensions. - `apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt` — replaced by a tamboui mapper. - `apps/tui/src/main/kotlin/com/correx/apps/tui/spike/SpikeMain.kt` — folded into `TuiApp.kt` and removed at the end of the migration. ### Files replaced - `input/MosaicKeyMapper.kt` → `input/TambouiKeyMapper.kt` (or folded into `TuiApp.kt` if the mapper stays small). ### Files untouched - Everything under `state/`, `reducer/`, `ws/`. - `input/Action.kt`, `input/KeyResolver.kt`, the domain `input/KeyEvent.kt`. ### Build changes - Drop `com.jakewharton.mosaic:mosaic-runtime`. - Drop `org.jetbrains.kotlin.plugin.compose` from the `apps/tui` plugins list. - Keep ktor, coroutines, kotlinx.serialization. - Add the tamboui dependencies and snapshot repo block above. ## Architecture - Single-threaded render thread owns `var state: TuiState`. - `EventHandler` reads tamboui events, maps them to domain actions, calls `RootReducer.reduce`, assigns the next state, and dispatches effects to the existing `EffectDispatcher` via a coroutine scope. - `Renderer` reads the current `state` and renders without side effects. - `TuiWsClient` keeps its `SharedFlow` and `SharedFlow`. A coroutine collects them and posts state transitions back to the render thread via `runner.runOnRenderThread { ... }`. Pick one bridging mechanism (runOnRenderThread vs. a channel polled on tick) and use it consistently; the spike uses `runOnRenderThread`. ### Skeleton ```kotlin fun main(): Unit = runBlocking { var state = TuiState() val ws = TuiWsClient(host = "localhost", port = 8080) val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) TuiRunner.create().use { runner -> fun dispatch(action: Action) { val (next, effects) = RootReducer.reduce(state, action) state = next effects.forEach { effectScope.launch { EffectDispatcher(ws, { runner.quit() }).dispatch(it) } } } // WS message bridge: collected on IO, posted to render thread. launch { ws.messages.collect { msg -> runner.runOnRenderThread { dispatch(Action.ServerEventReceived(msg)) } } } launch { ws.connection.collect { ev -> val action = when (ev) { is ConnectionEvent.Connected -> Action.Connected is ConnectionEvent.Disconnected -> Action.Disconnected is ConnectionEvent.RetryScheduled -> Action.RetryScheduled(ev.attempt, ev.nextRetryAtMs) } runner.runOnRenderThread { dispatch(action) } } } launch { ws.connect() } val handler = EventHandler { event, _ -> if (event is TambouiKeyEvent) { mapKey(event, state.input.mode) ?.let { KeyResolver.resolve(it, state.input.mode, state.input.text) } ?.let(::dispatch) } true } val renderer = Renderer { frame -> render(frame, state) } runner.run(handler, renderer) } } ``` ## Task Breakdown Each task is intentionally narrow so `/plan` can decompose it further. Numbers are sequencing hints, not commitments. ### T1 — Build wiring Drop Mosaic and the Compose plugin from `apps/tui/build.gradle`. Add the tamboui snapshot repo and dependencies. Rename the `spike` start script to `tui` and point it at the new `TuiAppKt` main class. Smoke-test `installDist` produces a working bin script (script runs and exits cleanly on the existing spike main as a placeholder). ### T2 — Tamboui key mapper Lift the spike's `mapKey` into `input/TambouiKeyMapper.kt`. Add unit tests covering every `(KeyCode, character, modifiers)` combination that `KeyResolver.resolve` consumes across every `InputMode`. Tests are pure JVM and do not start the runner. ### T3 — TuiApp shell Rewrite `TuiApp.kt` around `TuiRunner.create().use { ... }`. Wire the event handler dispatch loop, the render-thread state cell, and the coroutine bridges for WS messages, connection events, and effects. Preserve the existing `EffectDispatcher` contract. ### T4 — StatusBar Rewrite as a function returning a `Line` containing the connection badge and provider/session segments. Use `Span.styled` + named-color shortcuts; no manual padding. ### T5 — SessionList Rewrite as a `ListWidget` with `ListItem.from(Line)` rows. Per-status color comes from `Style.create().()`. Selection highlight via `.highlightStyle(...)` and `.highlightSymbol(...)`. Drive selection from domain state per Integration Pattern 3. **Status color mapping** (from `formatItem` in the spike): - `running` → `Style.create().green()` - `error` → `Style.create().red()` - `paused` → `Style.create().yellow()` - `completed` → `Style.create().dim().gray()` - `idle` → `Style.create().cyan()` - (default/unknown) → `Span.raw(s.status)` (no color) **Row composition** (from spike `formatItem`): - `[id]` segment: dim gray style, ID truncated to 6 chars, e.g., `Span.styled("[s1a2b3]", dimStyle)` - workflow ID segment: default style, quoted, e.g., `"wf-build-frontend"` - status segment: per-status color (see table above) - stage segment (if present): default style, e.g., ` stage compile` - ago segment: dim gray style, e.g., `12s ago` via `formatAgo()` **List widget configuration**: - `.highlightSymbol("▶ ")` — highlight prefix - `.highlightStyle(Style.create().cyan().reversed())` — selection styling ### T6 — InputBar Rewrite as a one-row paragraph with a mode-tinted prompt prefix and a visible cursor. Hide when `mode == None`. ### T7 — ApprovalPanel Rewrite as a `Block` (rounded borders, accent border color) wrapping a `Paragraph` whose body is a styled `Text`. Confirm/cancel hints styled `dim`. ### T8 — ActiveSession / scrollable log Rewrite the active-session view minimally. Task 4.1 in the progress log introduces a real scrollable log; **do that on top of tamboui after this migration lands**, not before. Carrying Mosaic-shaped log code into tamboui is explicitly wasted work. ### T9 — Delete dead code Remove `Panel.kt`, `TerminalSize.kt`, `MosaicKeyMapper.kt`, every `@Composable` annotation and Mosaic import, and `spike/SpikeMain.kt`. Verify no references remain (`grep -r mosaic apps/tui/`). ### T10 — Quality gates Restore detekt and kover gates on `apps/tui`. Run `./gradlew :apps:tui:check`. Smoke-test the produced bin script against a real running `apps/server` and walk every UX path (status, list, filter, approval, steering, reconnect). ## Tests - Reducer and key-mapper tests remain pure JVM and run in CI as today. - Render-level tests are out of scope for v1. Tamboui exposes an inspectable `Buffer`, but widget-level snapshot tests are not worth the maintenance cost before the API stabilizes. Manual smoke + the existing reducer coverage are the contract. - **Manual smoke is always `installDist` + bin script.** Never `./gradlew run`. ## Risks and Open Questions - **Snapshot dependency churn.** Mitigation: fork-to-pin if breakage occurs; do not fork-to-extend. - **Deferred features.** Mouse, scroll widgets, and richer focus handling are not promised in v1; they belong to a later epic. - **Mosaic mapper bug — fixed in the spike, must not regress.** Character keys (`q/n/c/a/r/s/'/'`) were eagerly mapped to semantic intents and silently swallowed inside input modes. The spike's `mapKey` takes the current `InputMode` and only does the semantic translation in `InputMode.None`. T2 must keep this shape and add a regression test. - **Effect-dispatch bridging mechanism.** Spike uses `runOnRenderThread`. If back-pressure or ordering becomes a problem, switch to a bounded channel polled on `TickEvent`. Decision deferred to T3. ## Out-of-Scope Follow-ups - Mouse support. - Tree widget for hierarchical workflow view. - CSS-like theming. - Devtools and widget snapshot testing. - Task 4.1 scrollable log (do after migration). - Fix for the inherited "char keys swallowed in input mode" bug.