Files
correx/docs/specs/2026-05-17-tamboui-migration.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

23 KiB
Raw Blame History

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.12.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)

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<SessionSummary> = 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<TuiState, List<Effect>>
  • TuiWsClient (apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt):

    • val messages: SharedFlow<ServerMessage> — emits server messages from the WebSocket stream.
    • val connection: SharedFlow<ConnectionEvent> — 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.TuiRunnerTuiRunner.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.EventHandlerboolean handle(Event, TuiRunner). Return true to consume.
  • dev.tamboui.tui.Renderervoid 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).
    • <S> renderStatefulWidget(StatefulWidget<S>, 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<Rect>. Manual Rect arithmetic is fine for trivial splits.

Widgets

  • dev.tamboui.widgets.block.BlockBlock.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.ParagraphParagraph.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.ListWidgetStatefulWidget<ListState>. Builder: .items(vararg String | vararg ListItem | List<SizedWidget>), .block(Block), .style(Style) (unselected), .highlightStyle(Style) (selected), .highlightSymbol(String|Line), .scrollbarThumbStyle(Style), .scrollbarTrackStyle(Style), .itemStyleResolver(BiFunction<Int,Int,Style>).
  • dev.tamboui.widgets.list.ListStatemutable. 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.SpanSpan.styled(String, Style), Span.raw(String). Chainable: .bold(), .italic(), .fg(Color), named-color shortcuts .red()/.green()/.cyan()/....
  • dev.tamboui.text.LineLine.from(vararg Span | List<Span>), 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:

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.

    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.ktinput/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<ServerMessage> and SharedFlow<ConnectionEvent>. 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

fun main() = 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().<color>(). Selection highlight via .highlightStyle(...) and .highlightSymbol(...). Drive selection from domain state per Integration Pattern 3.

Status color mapping (from formatItem in the spike):

  • runningStyle.create().green()
  • errorStyle.create().red()
  • pausedStyle.create().yellow()
  • completedStyle.create().dim().gray()
  • idleStyle.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.