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.
This commit is contained in:
2026-05-17 13:40:51 +04:00
parent 7d46f46f01
commit b267982005
15 changed files with 1123 additions and 281 deletions
+18 -19
View File
@@ -51,11 +51,7 @@
- **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).
---
## Pending — resume here
### Task 2.3 — EffectDispatcher + wire RootReducer into TuiApp 🚧 NEXT
### 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.reduce``Snapshot.withMutableSnapshot { state = next }` → trySend each `Effect` to a `Channel<Effect>`.
@@ -63,20 +59,32 @@
- 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`
### 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`
### 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
### 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)`.
@@ -84,15 +92,6 @@
- Rename `ActiveSession.kt``SessionOutputLog.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 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.
### 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.
@@ -104,8 +103,8 @@
## Sequencing
```
[done] 1.1 → 1.2 → 1.3 → 1.4 → 2.1 → 2.2
[next] 2.3 → 3.1 → 3.2 → 3.3 → {4.1, 4.2, 4.3} → 5.1 → 5.2
[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
+547
View File
@@ -0,0 +1,547 @@
# 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)
```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<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.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)`.
- `<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.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<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 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<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
```kotlin
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 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.