f827685ed0
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.
548 lines
23 KiB
Markdown
548 lines
23 KiB
Markdown
# Tamboui TUI Migration
|
||
|
||
- **Status:** draft
|
||
- **Owner:** kami
|
||
- **Date:** 2026-05-17
|
||
- **Relates to:** `docs/plans/2026-05-17-tui-refactor.md`, `docs/plans/2026-05-17-tui-refactor.progress.md`
|
||
- **Supersedes:** Mosaic-based renderer in `apps/tui`
|
||
|
||
## Motivation
|
||
|
||
The current TUI renderer is built on Mosaic 0.18. Mosaic is a Compose-for-terminal runtime
|
||
that has carried us through the initial event-sourced TUI but caps the level of polish we
|
||
can ship:
|
||
|
||
- No cell buffer — every frame is a string we hand-assemble.
|
||
- No widget catalog (no list, paragraph, table, scrollbar).
|
||
- No focus or mouse infrastructure.
|
||
- No scroll regions or viewport math.
|
||
- No real layout engine — we hand-draw borders and pad rows.
|
||
|
||
The recent TUI refactor (Tasks 2.1–2.3 in the progress log) deliberately split the app
|
||
into renderer-agnostic subsystems: `input/`, `reducer/`, `state/`, and `ws/` no longer
|
||
depend on Mosaic. The renderer surface is now small enough to swap.
|
||
|
||
A spike on a feature branch (now folded into `master`) proved that tamboui hosts the
|
||
existing `RootReducer` and `TuiState` without changes. The working spike lives at
|
||
`apps/tui/src/main/kotlin/com/correx/apps/tui/spike/SpikeMain.kt` (~218 lines) and is
|
||
the canonical reference for the migration.
|
||
|
||
## Goals
|
||
|
||
- Replace Mosaic with tamboui as the renderer for `apps/tui`.
|
||
- Preserve current UX: status bar, session list with filter, approval flow, steering
|
||
input, reconnect countdown.
|
||
- Keep `state/`, `reducer/`, `ws/`, and `input/` (minus the Mosaic key mapper)
|
||
untouched. The renderer-agnostic core stays renderer-agnostic.
|
||
- Land the migration on the current `0.2.1-SNAPSHOT` line of tamboui without forking.
|
||
|
||
## Non-Goals
|
||
|
||
- No changes to the WebSocket protocol or `TuiWsClient` flows.
|
||
- No reducer or state-shape changes.
|
||
- No new features. The scrollable log work in Task 4.1 of the progress log stays its
|
||
own task and should be redone on top of tamboui rather than ported.
|
||
- No fork of tamboui to extend its API. Fork-to-pin is the only sanctioned escape hatch
|
||
if upstream snapshot churn breaks us.
|
||
- No mouse, no theming engine, no devtools, no snapshot tests for widgets.
|
||
|
||
## Library Facts (pin these)
|
||
|
||
### Gradle coordinates (Groovy DSL)
|
||
|
||
```groovy
|
||
repositories {
|
||
maven {
|
||
url 'https://central.sonatype.com/repository/maven-snapshots/'
|
||
mavenContent { snapshotsOnly() }
|
||
}
|
||
}
|
||
configurations.all {
|
||
resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
|
||
}
|
||
dependencies {
|
||
implementation platform('dev.tamboui:tamboui-bom:0.2.1-SNAPSHOT')
|
||
implementation 'dev.tamboui:tamboui-tui'
|
||
implementation 'dev.tamboui:tamboui-widgets'
|
||
implementation 'dev.tamboui:tamboui-core'
|
||
runtimeOnly 'dev.tamboui:tamboui-jline3-backend'
|
||
}
|
||
```
|
||
|
||
### Maturity
|
||
|
||
- License: MIT.
|
||
- Distribution: snapshot-only.
|
||
- API stability: experimental, "APIs may/will change."
|
||
- Bytecode: Java 8. Multi-release jar; `META-INF/versions/11` is module-info only.
|
||
- JDK 21 (our toolchain) is fine.
|
||
|
||
### Stability strategy
|
||
|
||
Stay on `0.2.1-SNAPSHOT`. If upstream breaks us, **fork-to-pin** (clone the upstream
|
||
repo at the known-good commit, `publishToMavenLocal`, swap coordinates). Do **not**
|
||
fork-to-extend — tamboui is a TUI framework project, not part of correx's scope.
|
||
|
||
## Domain Reference
|
||
|
||
- **KeyEvent** (`apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt`): sealed class with variants
|
||
- `object Quit`
|
||
- `object NewSession`
|
||
- `object Cancel`
|
||
- `object Approve`
|
||
- `object Reject`
|
||
- `object Steer`
|
||
- `object NavUp`
|
||
- `object NavDown`
|
||
- `object Filter`
|
||
- `object Enter`
|
||
- `object Backspace`
|
||
- `object Escape`
|
||
- `data class CharInput(val ch: Char)`
|
||
|
||
- **Action** (`apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt`): sealed interface with variants
|
||
- `data object Quit`
|
||
- `data object OpenNewSessionPrompt`
|
||
- `data object CancelSelectedSession`
|
||
- `data object ApproveActive`
|
||
- `data object RejectActive`
|
||
- `data object OpenSteeringPrompt`
|
||
- `data object NavigateUp`
|
||
- `data object NavigateDown`
|
||
- `data object OpenFilter`
|
||
- `data class AppendChar(val ch: Char)`
|
||
- `data object Backspace`
|
||
- `data object SubmitInput`
|
||
- `data object CancelInput`
|
||
- `data class ServerEventReceived(val message: ServerMessage)`
|
||
- `data object Connected`
|
||
- `data object Disconnected`
|
||
- `data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long)`
|
||
|
||
- **InputMode** (`apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt`): enum with variants `None`, `WorkflowId`, `SteeringNote`, `Filter`
|
||
|
||
- **InputState**: `data class InputState(val mode: InputMode = InputMode.None, val text: String = "")`
|
||
|
||
- **TuiState** (`apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt`): composition of slices
|
||
- `connection: ConnectionState`
|
||
- `sessions: SessionsState`
|
||
- `input: InputState`
|
||
- `approval: ApprovalState`
|
||
- `provider: ProviderState`
|
||
|
||
- **SessionsState**: `data class SessionsState(val sessions: List<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(): 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 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.
|