fixed detekt issues where possible. fixed disttar failing build because tools is added twice in the server module. added workflowId where required. fixed some tests not being recognized because of runBlocking without explicit return type. formatting + imports.
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(): 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().<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.