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
+30 -3
View File
@@ -1,12 +1,22 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose'
id 'application'
}
application {
mainClass = 'com.correx.apps.tui.MainKt'
mainClass = 'com.correx.apps.tui.TuiAppKt'
}
repositories {
maven {
url 'https://central.sonatype.com/repository/maven-snapshots/'
mavenContent { snapshotsOnly() }
}
}
configurations.all {
resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
}
dependencies {
@@ -19,7 +29,11 @@ dependencies {
implementation "io.ktor:ktor-client-websockets:$ktor_version"
implementation "io.ktor:ktor-client-logging:$ktor_version"
implementation "com.jakewharton.mosaic:mosaic-runtime:0.18.0"
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'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version"
@@ -31,6 +45,19 @@ dependencies {
tasks.named('test') { useJUnitPlatform() }
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' }
}
}
tasks.withType(Tar).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
tasks.withType(Zip).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
tasks.named("installDist") { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
@@ -1,11 +0,0 @@
package com.correx.apps.tui
import kotlinx.coroutines.runBlocking
private const val DEFAULT_PORT = 8080
fun main(args: Array<String>) {
val host = args.getOrElse(0) { "localhost" }
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
runBlocking { runTuiApp(host, port) }
}
@@ -1,111 +1,143 @@
package com.correx.apps.tui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import com.correx.apps.tui.components.ActiveSession
import com.correx.apps.tui.components.ApprovalPanel
import com.correx.apps.tui.components.InputBar
import com.correx.apps.tui.components.LocalTerminalSize
import com.correx.apps.tui.components.Panel
import com.correx.apps.tui.components.SessionList
import com.correx.apps.tui.components.StatusBar
import com.correx.apps.tui.components.readTerminalSize
import com.correx.apps.tui.components.inputBarLine
import com.correx.apps.tui.components.renderApprovalPanel
import com.correx.apps.tui.components.sessionListWidget
import com.correx.apps.tui.components.statusBarLine
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.input.KeyResolver
import com.correx.apps.tui.input.MosaicKeyMapper
import com.correx.apps.tui.input.mapKey
import com.correx.apps.tui.reducer.EffectDispatcher
import com.correx.apps.tui.reducer.Effect
import com.correx.apps.tui.reducer.RootReducer
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.ws.ConnectionEvent
import com.correx.apps.tui.ws.TuiWsClient
import com.jakewharton.mosaic.layout.onKeyEvent
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.runMosaic
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import dev.tamboui.layout.Rect
import dev.tamboui.style.Style
import dev.tamboui.terminal.Frame
import dev.tamboui.text.Text
import dev.tamboui.tui.EventHandler
import dev.tamboui.tui.Renderer
import dev.tamboui.tui.TuiRunner
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
import dev.tamboui.widgets.list.ListState
import dev.tamboui.widgets.paragraph.Paragraph
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
private const val TERMINAL_SIZE_POLL_MS = 500L
private const val DEFAULT_PORT = 8080
private const val APPROVAL_HEIGHT = 6
private const val STATUS_ROWS = 1
private const val HELP_ROWS = 1
@Suppress("FunctionNaming")
@Composable
private fun Keybinds() {
Text("n new r resume c cancel a approve q quit ↑↓ navigate")
}
suspend fun runTuiApp(host: String, port: Int) {
var state by mutableStateOf(TuiState())
var quit by mutableStateOf(false)
var terminalSize by mutableStateOf(readTerminalSize())
val effects = Channel<Effect>(Channel.BUFFERED)
fun main(args: Array<String>) {
val host = args.getOrElse(0) { "localhost" }
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
runBlocking {
var state = TuiState()
val ws = TuiWsClient(host = host, port = port)
val dispatcher = EffectDispatcher(ws) { quit = true }
val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val listState = ListState()
TuiRunner.create().use { runner ->
fun dispatch(action: Action) {
val (next, emitted) = RootReducer.reduce(state, action)
Snapshot.withMutableSnapshot { state = next }
emitted.forEach { effects.trySend(it) }
val (next, effects) = RootReducer.reduce(state, action)
state = next
effects.forEach { effect ->
effectScope.launch {
EffectDispatcher(ws) { runner.quit() }.dispatch(effect)
}
}
}
coroutineScope {
launch { ws.connect() }
launch { ws.messages.collect { dispatch(Action.ServerEventReceived(it)) } }
launch {
ws.connection.collect { event ->
dispatch(
when (event) {
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(event.attempt, event.nextRetryAtMs)
},
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, listState) }
runner.run(handler, renderer)
}
}
}
private fun render(frame: Frame, state: TuiState, listState: ListState) {
val total = frame.area()
val hasInput = state.input.mode != InputMode.None
val listHeight = if (hasInput) {
total.height() - STATUS_ROWS - HELP_ROWS - 1
} else {
total.height() - STATUS_ROWS - HELP_ROWS
}
val statusArea = Rect(total.x(), total.y(), total.width(), STATUS_ROWS)
val listArea = Rect(total.x(), total.y() + STATUS_ROWS, total.width(), listHeight)
val inputArea = if (hasInput) {
Rect(total.x(), total.y() + STATUS_ROWS + listHeight, total.width(), 1)
} else {
null
}
val helpArea = Rect(total.x(), total.y() + total.height() - HELP_ROWS, total.width(), HELP_ROWS)
frame.renderWidget(
Paragraph.builder().text(Text.from(statusBarLine(state))).build(),
statusArea
)
frame.renderStatefulWidget(sessionListWidget(state.sessions, listState), listArea, listState)
if (inputArea != null) {
val line = inputBarLine(state.input)
if (line != null) {
frame.renderWidget(
Paragraph.builder().text(Text.from(line)).build(),
inputArea
)
}
}
launch { for (e in effects) dispatcher.dispatch(e) }
launch {
while (isActive) {
delay(TERMINAL_SIZE_POLL_MS)
val current = readTerminalSize()
if (current != terminalSize) Snapshot.withMutableSnapshot { terminalSize = current }
}
val approvalActive = state.approval.active
if (approvalActive != null && listHeight > APPROVAL_HEIGHT) {
val approvalY = total.y() + STATUS_ROWS + (listHeight - APPROVAL_HEIGHT).coerceAtLeast(0)
val approvalArea = Rect(total.x(), approvalY, total.width(), APPROVAL_HEIGHT.coerceAtMost(listHeight))
renderApprovalPanel(frame, approvalActive, approvalArea)
}
runMosaic {
CompositionLocalProvider(LocalTerminalSize provides terminalSize) {
Column(
modifier = Modifier.onKeyEvent { mosaicKey ->
val key = MosaicKeyMapper.toDomain(mosaicKey) ?: return@onKeyEvent false
val action = KeyResolver.resolve(key, state.input.mode, state.input.text)
?: return@onKeyEvent true
dispatch(action)
true
},
) {
Panel(title = "status") { StatusBar(state) }
Panel(title = "session list") {
SessionList(state.sessions.sessions, state.sessions.selectedId, state.sessions.filter)
}
val activeSession = state.sessions.sessions.find { it.id == state.sessions.selectedId }
Panel(title = "active session") { ActiveSession(activeSession) }
if (state.approval.active != null) {
Panel(title = "approval") { ApprovalPanel(state.approval.active!!) }
}
Panel(title = "input") { InputBar(state.input) }
Panel(title = "keybinds") { Keybinds() }
}
}
}
val dimStyle = Style.create().dim().gray()
frame.renderWidget(
Paragraph.builder().text(helpText(state.input.mode)).style(dimStyle).left().build(),
helpArea
)
}
ws.close()
private fun helpText(mode: InputMode): String = when (mode) {
InputMode.None -> "q quit ↑/↓ select n new / filter a approve r reject s steer c cancel"
InputMode.Filter -> "enter apply esc cancel"
InputMode.WorkflowId -> "enter start session esc cancel"
InputMode.SteeringNote -> "enter send note esc cancel"
}
@@ -1,19 +1,21 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.SessionSummary
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
import dev.tamboui.layout.Rect
import dev.tamboui.terminal.Frame
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.paragraph.Paragraph
@Suppress("FunctionNaming")
@Composable
fun ActiveSession(session: SessionSummary?) {
Column {
if (session == null) {
Text("(no active session selected)")
fun renderActiveSession(frame: Frame, session: SessionSummary?, area: Rect) {
val text = if (session == null) {
Text.from(Line.from(Span.raw("(no active session selected)")))
} else {
Text("stage: ${session.currentStage ?: "—"}")
Text("last output: ${session.lastOutput ?: "—"}")
}
Text.from(
Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")),
Line.from(Span.raw("last output: ${session.lastOutput ?: "—"}"))
)
}
frame.renderWidget(Paragraph.builder().text(text).build(), area)
}
@@ -1,18 +1,35 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.ApprovalInfo
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
import dev.tamboui.layout.Rect
import dev.tamboui.style.Color
import dev.tamboui.style.Style
import dev.tamboui.terminal.Frame
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
@Suppress("FunctionNaming")
@Composable
fun ApprovalPanel(approval: ApprovalInfo) {
Column {
Text("⚠ APPROVAL REQUIRED — Tier ${approval.tier}")
approval.toolName?.let { Text("tool: $it") }
Text("risk: ${approval.riskSummary}")
approval.preview?.let { Text("preview: $it") }
Text("[A] approve [R] reject [S] steer")
fun renderApprovalPanel(frame: Frame, approval: ApprovalInfo, area: Rect) {
val dimStyle = Style.create().dim().gray()
val lines = buildList<Line> {
add(Line.from(Span.styled("⚠ APPROVAL REQUIRED — Tier ${approval.tier}", Style.create().yellow().bold())))
approval.toolName?.let { add(Line.from(Span.raw("tool: $it"))) }
add(Line.from(Span.raw("risk: ${approval.riskSummary}")))
approval.preview?.let { add(Line.from(Span.raw("preview: $it"))) }
add(Line.from(Span.styled("[A] approve [R] reject", dimStyle)))
}
val block = Block.builder()
.title("approval")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.borderColor(Color.YELLOW)
.build()
frame.renderWidget(
Paragraph.builder().text(Text.from(lines)).block(block).build(),
area
)
}
@@ -1,18 +1,21 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.InputState
import com.jakewharton.mosaic.ui.Text
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
@Suppress("FunctionNaming")
@Composable
fun InputBar(input: InputState) {
val label = when (input.mode) {
InputMode.None -> ">"
InputMode.WorkflowId -> "workflow id:"
InputMode.SteeringNote -> "steering note:"
InputMode.Filter -> "filter:"
fun inputBarLine(input: InputState): Line? {
if (input.mode == InputMode.None) return null
val (promptText, promptStyle) = when (input.mode) {
InputMode.WorkflowId -> "new session " to Style.create().cyan()
InputMode.Filter -> "filter " to Style.create().yellow()
InputMode.SteeringNote -> "steer " to Style.create().magenta()
InputMode.None -> "" to Style.create()
}
Text("$label ${input.text}")
return Line.from(
Span.styled(promptText, promptStyle),
Span.raw(input.text + "")
)
}
@@ -1,22 +0,0 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
@Suppress("FunctionNaming")
@Composable
fun Panel(title: String?, content: @Composable () -> Unit) {
val width = LocalTerminalSize.current.width
Column {
Text(headerLine(title, width))
content()
}
}
private fun headerLine(title: String?, width: Int): String {
if (title == null) return "" + "".repeat((width - 2).coerceAtLeast(0)) + ""
val label = "$title "
val fill = (width - 2 - label.length).coerceAtLeast(0)
return "$label" + "".repeat(fill) + ""
}
@@ -1,44 +1,73 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.SessionSummary
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
import com.correx.apps.tui.state.SessionsState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.list.ListItem
import dev.tamboui.widgets.list.ListState
import dev.tamboui.widgets.list.ListWidget
private const val SESSION_ID_DISPLAY_LENGTH = 6
private const val MS_PER_SECOND = 1000L
private const val SECS_PER_MINUTE = 60L
private const val SECS_PER_HOUR = 3600L
private const val MS_PER_SEC = 1000L
private const val SEC_PER_MIN = 60L
private const val SEC_PER_HOUR = 3600L
private const val ID_LEN = 6
@Suppress("FunctionNaming")
@Composable
fun SessionList(sessions: List<SessionSummary>, selectedId: String?, filter: String = "") {
Column {
val filtered = if (filter.isEmpty()) {
sessions
} else {
sessions.filter { it.workflowId.contains(filter, ignoreCase = true) }
}
if (filtered.isEmpty()) {
Text("(no sessions)")
} else {
filtered.forEach { session ->
val pointer = if (session.id == selectedId) "" else " "
val stage = session.currentStage?.let { " stage $it" } ?: ""
val ago = formatAgo(session.lastEventAt)
val shortId = session.id.take(SESSION_ID_DISPLAY_LENGTH)
Text("$pointer [$shortId] \"${session.workflowId}\" ${session.status}$stage $ago")
}
}
}
fun sessionListWidget(sessions: SessionsState, listState: ListState): ListWidget {
val filtered = filteredSessions(sessions)
val items = filtered.map { ListItem.from(formatSessionItem(it)) }
val selIdx = filtered.indexOfFirst { it.id == sessions.selectedId }.let { if (it < 0) 0 else it }
listState.select(selIdx)
@Suppress("SpreadOperator")
return ListWidget.builder()
.items(*items.toTypedArray())
.highlightStyle(Style.create().cyan().reversed())
.highlightSymbol("")
.block(
Block.builder()
.title("sessions")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
)
.build()
}
private fun formatAgo(epochMs: Long): String {
val diffMs = System.currentTimeMillis() - epochMs
val secs = diffMs / MS_PER_SECOND
fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
val f = sessions.filter
return if (f.isEmpty()) sessions.sessions
else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) }
}
fun formatSessionItem(s: SessionSummary): Line {
val dimStyle = Style.create().dim().gray()
val statusSpan = when (s.status) {
"running" -> Span.styled(s.status, Style.create().green())
"error" -> Span.styled(s.status, Style.create().red())
"paused" -> Span.styled(s.status, Style.create().yellow())
"completed" -> Span.styled(s.status, dimStyle)
"idle" -> Span.styled(s.status, Style.create().cyan())
else -> Span.raw(s.status)
}
val stage = s.currentStage?.let { " stage $it" } ?: ""
return Line.from(
Span.styled("[${s.id.take(ID_LEN)}]", dimStyle),
Span.raw(" \"${s.workflowId}\" "),
statusSpan,
Span.raw(stage),
Span.styled(" ${formatAgo(s.lastEventAt)}", dimStyle)
)
}
fun formatAgo(epochMs: Long): String {
val secs = (System.currentTimeMillis() - epochMs) / MS_PER_SEC
return when {
secs < SECS_PER_MINUTE -> "${secs}s ago"
secs < SECS_PER_HOUR -> "${secs / SECS_PER_MINUTE}m ago"
else -> "${secs / SECS_PER_HOUR}h ago"
secs < SEC_PER_MIN -> "${secs}s ago"
secs < SEC_PER_HOUR -> "${secs / SEC_PER_MIN}m ago"
else -> "${secs / SEC_PER_HOUR}h ago"
}
}
@@ -1,31 +1,41 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.TuiState
import com.jakewharton.mosaic.ui.Text
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
private const val MS_PER_SECOND = 1000L
private const val MS_PER_SEC = 1000L
@Suppress("FunctionNaming")
@Composable
fun StatusBar(state: TuiState) {
val connectionLabel = when {
fun statusBarLine(state: TuiState): Line {
val connSpans = when {
state.connection.reconnecting -> {
val secondsRemaining = if (state.connection.nextRetryAtMs != null) {
val now = System.currentTimeMillis()
maxOf(0, (state.connection.nextRetryAtMs - now) / MS_PER_SECOND)
} else {
0
val rem = state.connection.nextRetryAtMs?.let {
maxOf(0, (it - System.currentTimeMillis()) / MS_PER_SEC)
} ?: 0
listOf(
Span.styled("", Style.create().yellow()),
Span.raw("reconnecting (attempt ${state.connection.attempt}, retry in ${rem}s)")
)
}
"○ reconnecting (attempt ${state.connection.attempt}, retry in ${secondsRemaining}s)"
state.connection.connected -> listOf(
Span.styled("", Style.create().green()),
Span.raw("connected")
)
else -> listOf(
Span.styled("", Style.create().dim().gray()),
Span.raw("disconnected")
)
}
state.connection.connected -> "● connected"
else -> "○ disconnected"
}
val providerLabel = if (state.provider.id.isNotEmpty()) {
val prov = if (state.provider.id.isNotEmpty()) {
"${state.provider.id} (${state.provider.status})"
} else {
state.provider.status
}
Text("server: $connectionLabel │ provider: $providerLabel")
return Line.from(
connSpans + listOf(
Span.raw(" │ provider: "),
Span.raw(prov)
)
)
}
@@ -1,18 +0,0 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.staticCompositionLocalOf
data class TerminalSize(val width: Int, val height: Int)
val LocalTerminalSize = staticCompositionLocalOf { TerminalSize(width = 80, height = 24) }
internal fun readTerminalSize(): TerminalSize = runCatching {
val process = ProcessBuilder("stty", "size")
.redirectErrorStream(true)
.redirectInput(java.io.File("/dev/tty"))
.start()
val line = process.inputStream.bufferedReader().readLine().orEmpty()
process.waitFor()
val parts = line.trim().split(" ")
TerminalSize(width = parts[1].toInt(), height = parts[0].toInt())
}.getOrDefault(TerminalSize(width = 80, height = 24))
@@ -1,29 +0,0 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import com.jakewharton.mosaic.layout.KeyEvent as MosaicKeyEvent
object MosaicKeyMapper {
@Suppress("CyclomaticComplexMethod")
fun toDomain(event: MosaicKeyEvent): KeyEvent? {
if (event.ctrl || event.alt) return null
return when (event.key) {
"q" -> KeyEvent.Quit
"n" -> KeyEvent.NewSession
"c" -> KeyEvent.Cancel
"a" -> KeyEvent.Approve
"r" -> KeyEvent.Reject
"s" -> KeyEvent.Steer
"/" -> KeyEvent.Filter
"ArrowUp" -> KeyEvent.NavUp
"ArrowDown" -> KeyEvent.NavDown
"Enter" -> KeyEvent.Enter
"Backspace" -> KeyEvent.Backspace
"Escape" -> KeyEvent.Escape
else -> {
val ch = event.key.singleOrNull()
if (ch != null && !ch.isISOControl()) KeyEvent.CharInput(ch) else null
}
}
}
}
@@ -0,0 +1,36 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.InputMode
import dev.tamboui.tui.event.KeyCode
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
@Suppress("CyclomaticComplexMethod", "ReturnCount")
fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? {
if (event.isCtrlC()) return KeyEvent.Quit
if (event.hasCtrl() || event.hasAlt()) return null
return when (event.code()) {
KeyCode.UP -> KeyEvent.NavUp
KeyCode.DOWN -> KeyEvent.NavDown
KeyCode.ENTER -> KeyEvent.Enter
KeyCode.ESCAPE -> KeyEvent.Escape
KeyCode.BACKSPACE -> KeyEvent.Backspace
KeyCode.TAB -> KeyEvent.Filter
KeyCode.CHAR -> {
val ch = event.character()
if (ch.isISOControl()) return null
if (mode != InputMode.None) return KeyEvent.CharInput(ch)
when (ch) {
'q' -> KeyEvent.Quit
'n' -> KeyEvent.NewSession
'c' -> KeyEvent.Cancel
'a' -> KeyEvent.Approve
'r' -> KeyEvent.Reject
's' -> KeyEvent.Steer
'/' -> KeyEvent.Filter
else -> KeyEvent.CharInput(ch)
}
}
else -> null
}
}
@@ -0,0 +1,220 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.InputMode
import dev.tamboui.tui.event.KeyCode
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
import dev.tamboui.tui.event.KeyModifiers
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class TambouiKeyMapperTest {
// --- Ctrl-C always → Quit ---
@Test
fun `ctrl-c maps to Quit in InputMode None`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.None))
}
@Test
fun `ctrl-c maps to Quit in InputMode Filter`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.Filter))
}
@Test
fun `ctrl-c maps to Quit in InputMode WorkflowId`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.WorkflowId))
}
@Test
fun `ctrl-c maps to Quit in InputMode SteeringNote`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.SteeringNote))
}
// --- Other Ctrl/Alt → null ---
@Test
fun `ctrl modifier non-ctrlC maps to null`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code)
assertNull(mapKey(event, InputMode.None))
}
@Test
fun `alt modifier maps to null`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code)
assertNull(mapKey(event, InputMode.None))
}
// --- KeyCode mappings in InputMode.None ---
@Test
fun `UP maps to NavUp in InputMode None`() {
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.None))
}
@Test
fun `DOWN maps to NavDown in InputMode None`() {
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.None))
}
@Test
fun `ENTER maps to Enter in InputMode None`() {
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.None))
}
@Test
fun `ESCAPE maps to Escape in InputMode None`() {
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.None))
}
@Test
fun `BACKSPACE maps to Backspace in InputMode None`() {
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.None))
}
@Test
fun `TAB maps to Filter in InputMode None`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.None))
}
// --- KeyCode mappings in InputMode.Filter ---
@Test
fun `UP maps to NavUp in InputMode Filter`() {
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.Filter))
}
@Test
fun `DOWN maps to NavDown in InputMode Filter`() {
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.Filter))
}
@Test
fun `ENTER maps to Enter in InputMode Filter`() {
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.Filter))
}
@Test
fun `ESCAPE maps to Escape in InputMode Filter`() {
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.Filter))
}
@Test
fun `BACKSPACE maps to Backspace in InputMode Filter`() {
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.Filter))
}
@Test
fun `TAB maps to Filter in InputMode Filter`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.Filter))
}
// --- Semantic intents in InputMode.None ---
@Test
fun `q maps to Quit in InputMode None`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent.ofChar('q'), InputMode.None))
}
@Test
fun `n maps to NewSession in InputMode None`() {
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent.ofChar('n'), InputMode.None))
}
@Test
fun `c maps to Cancel in InputMode None`() {
assertEquals(KeyEvent.Cancel, mapKey(TambouiKeyEvent.ofChar('c'), InputMode.None))
}
@Test
fun `a maps to Approve in InputMode None`() {
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent.ofChar('a'), InputMode.None))
}
@Test
fun `r maps to Reject in InputMode None`() {
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent.ofChar('r'), InputMode.None))
}
@Test
fun `s maps to Steer in InputMode None`() {
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent.ofChar('s'), InputMode.None))
}
@Test
fun `slash maps to Filter in InputMode None`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofChar('/'), InputMode.None))
}
@Test
fun `other char maps to CharInput in InputMode None`() {
assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'), InputMode.None))
}
// --- Regression: semantic chars map to CharInput in non-None modes ---
@Test
fun `q maps to CharInput in InputMode WorkflowId`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.WorkflowId))
}
@Test
fun `n maps to CharInput in InputMode WorkflowId`() {
assertEquals(KeyEvent.CharInput('n'), mapKey(TambouiKeyEvent.ofChar('n'), InputMode.WorkflowId))
}
@Test
fun `c maps to CharInput in InputMode WorkflowId`() {
assertEquals(KeyEvent.CharInput('c'), mapKey(TambouiKeyEvent.ofChar('c'), InputMode.WorkflowId))
}
@Test
fun `a maps to CharInput in InputMode WorkflowId`() {
assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'), InputMode.WorkflowId))
}
@Test
fun `r maps to CharInput in InputMode WorkflowId`() {
assertEquals(KeyEvent.CharInput('r'), mapKey(TambouiKeyEvent.ofChar('r'), InputMode.WorkflowId))
}
@Test
fun `s maps to CharInput in InputMode WorkflowId`() {
assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'), InputMode.WorkflowId))
}
@Test
fun `slash maps to CharInput in InputMode WorkflowId`() {
assertEquals(KeyEvent.CharInput('/'), mapKey(TambouiKeyEvent.ofChar('/'), InputMode.WorkflowId))
}
@Test
fun `q maps to CharInput in InputMode SteeringNote`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.SteeringNote))
}
@Test
fun `q maps to CharInput in InputMode Filter`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.Filter))
}
// --- ISO control chars in CHAR event → null ---
@Test
fun `ESC codepoint as CHAR event maps to null`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B)
assertNull(mapKey(event, InputMode.None))
}
@Test
fun `NUL codepoint as CHAR event maps to null`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00)
assertNull(mapKey(event, InputMode.None))
}
}
+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.