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.
23 KiB
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/, andinput/(minus the Mosaic key mapper) untouched. The renderer-agnostic core stays renderer-agnostic. - Land the migration on the current
0.2.1-SNAPSHOTline of tamboui without forking.
Non-Goals
- No changes to the WebSocket protocol or
TuiWsClientflows. - 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/11is 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 variantsobject Quitobject NewSessionobject Cancelobject Approveobject Rejectobject Steerobject NavUpobject NavDownobject Filterobject Enterobject Backspaceobject Escapedata class CharInput(val ch: Char)
-
Action (
apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt): sealed interface with variantsdata object Quitdata object OpenNewSessionPromptdata object CancelSelectedSessiondata object ApproveActivedata object RejectActivedata object OpenSteeringPromptdata object NavigateUpdata object NavigateDowndata object OpenFilterdata class AppendChar(val ch: Char)data object Backspacedata object SubmitInputdata object CancelInputdata class ServerEventReceived(val message: ServerMessage)data object Connecteddata object Disconnecteddata class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long)
-
InputMode (
apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt): enum with variantsNone,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 slicesconnection: ConnectionStatesessions: SessionsStateinput: InputStateapproval: ApprovalStateprovider: 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 variantsdata 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 Connecteddata object Disconnecteddata 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 anAutoCloseable.- 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). Returntrueto consume.dev.tamboui.tui.Renderer—void render(Frame).- Both callbacks run on the single render thread; a plain
var statefield is thread-safe as long as nothing else mutates it.
Events
dev.tamboui.tui.event.Event(sealed) with variantsKeyEvent,MouseEvent,ResizeEvent,TickEvent.KeyEventAPI:code(): KeyCode,character(): Char,hasCtrl(),hasAlt(),hasShift(),isCtrlC(),isKey(KeyCode),isCharIgnoreCase(Char).KeyCodeenum:UP, DOWN, LEFT, RIGHT, ENTER, ESCAPE, BACKSPACE, TAB, CHAR, ....
Frame and layout
dev.tamboui.terminal.Frame(notdev.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.Layoutbuilder:Layout.vertical()/horizontal().constraints(Constraint.length(n), Constraint.fill(), Constraint.ratio(a,b)).spacing(n).split(area)returnsList<Rect>. ManualRectarithmetic 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 andParagraph.builder().text(String|Line|Text).style(Style).left()/center()/right() .block(Block).foreground(Color).background(Color).build().dev.tamboui.widgets.list.ListWidget—StatefulWidget<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.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<Span>),Line.styled(String, Style). Chainable:.bold(),.fg(Color),.alignment(LEFT|CENTER|RIGHT),.append(Span).dev.tamboui.text.Text.from(Line)wraps aLineforParagraph.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— constantsBLACK, 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.Modifierenum: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)
-
Key mapping. Translate tamboui
KeyEventinto the domainKeyEvent(com.correx.apps.tui.KeyEvent, not underinput/). The mapper is mode-aware: inInputMode.None, character keys map to semantic intents ('q' → Quit,'n' → NewSession, etc.); in every other mode, character keys map toCharInput(ch)so they reach the input buffer. This fixes a latent Mosaic bug (MosaicKeyMapperis unconditional and silently swallowsq/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 } } -
Dispatch loop.
dispatch(action)callsRootReducer.reduce(state, action), assigns the returned state, and discards effects for now. Real effect plumbing is its own task. -
List state sync. Each frame, compute the visual index of
state.sessions.selectedIdwithin the filtered list and calllistState.select(idx)beforerenderStatefulWidget. Domain state owns selection;ListStateis a view detail. -
Layout shape.
[status 1 row][sessions fill][input 1 row when mode != None] [help 1 row dim]. -
KeyResolver signature.
KeyResolver.resolve(key, mode, inputText)takes three arguments;inputTextis required becauseSubmitInputis 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.ktapps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.ktapps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.ktapps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.ktapps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.ktapps/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— tambouiBlockreplaces 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 intoTuiApp.ktand removed at the end of the migration.
Files replaced
input/MosaicKeyMapper.kt→input/TambouiKeyMapper.kt(or folded intoTuiApp.ktif the mapper stays small).
Files untouched
- Everything under
state/,reducer/,ws/. input/Action.kt,input/KeyResolver.kt, the domaininput/KeyEvent.kt.
Build changes
- Drop
com.jakewharton.mosaic:mosaic-runtime. - Drop
org.jetbrains.kotlin.plugin.composefrom theapps/tuiplugins list. - Keep ktor, coroutines, kotlinx.serialization.
- Add the tamboui dependencies and snapshot repo block above.
Architecture
- Single-threaded render thread owns
var state: TuiState. EventHandlerreads tamboui events, maps them to domain actions, callsRootReducer.reduce, assigns the next state, and dispatches effects to the existingEffectDispatchervia a coroutine scope.Rendererreads the currentstateand renders without side effects.TuiWsClientkeeps itsSharedFlow<ServerMessage>andSharedFlow<ConnectionEvent>. A coroutine collects them and posts state transitions back to the render thread viarunner.runOnRenderThread { ... }. Pick one bridging mechanism (runOnRenderThread vs. a channel polled on tick) and use it consistently; the spike usesrunOnRenderThread.
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):
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 agoviaformatAgo()
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'smapKeytakes the currentInputModeand only does the semantic translation inInputMode.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 onTickEvent. 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.