Files
correx/docs/specs/2026-05-17-tamboui-migration.md
kami f827685ed0 chore(damn): detekt, build, tests, formatting
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.
2026-05-22 00:10:05 +04:00

548 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(): 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.