diff --git a/apps/tui/build.gradle b/apps/tui/build.gradle index 44f1854a..15970123 100644 --- a/apps/tui/build.gradle +++ b/apps/tui/build.gradle @@ -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 } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/Main.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/Main.kt deleted file mode 100644 index c3197fa3..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/Main.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.correx.apps.tui - -import kotlinx.coroutines.runBlocking - -private const val DEFAULT_PORT = 8080 - -fun main(args: Array) { - val host = args.getOrElse(0) { "localhost" } - val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT - runBlocking { runTuiApp(host, port) } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt index 4f6a7307..ca59f3a4 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt @@ -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") -} +fun main(args: Array) { + 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 effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val listState = ListState() -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(Channel.BUFFERED) - val ws = TuiWsClient(host = host, port = port) - val dispatcher = EffectDispatcher(ws) { quit = true } - - fun dispatch(action: Action) { - val (next, emitted) = RootReducer.reduce(state, action) - Snapshot.withMutableSnapshot { state = next } - emitted.forEach { effects.trySend(it) } - } - - coroutineScope { - launch { ws.connect() } - launch { ws.messages.collect { dispatch(Action.ServerEventReceived(it)) } } - launch { - ws.connection.collect { event -> - dispatch( - when (event) { - is ConnectionEvent.Connected -> Action.Connected - is ConnectionEvent.Disconnected -> Action.Disconnected - is ConnectionEvent.RetryScheduled -> Action.RetryScheduled(event.attempt, event.nextRetryAtMs) - }, - ) - } - } - 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 } - } - } - - 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) + TuiRunner.create().use { runner -> + fun dispatch(action: Action) { + val (next, effects) = RootReducer.reduce(state, action) + state = next + effects.forEach { effect -> + effectScope.launch { + EffectDispatcher(ws) { runner.quit() }.dispatch(effect) } - 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() } } } + + 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, 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 + ) } } - ws.close() + 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) + } + + val dimStyle = Style.create().dim().gray() + frame.renderWidget( + Paragraph.builder().text(helpText(state.input.mode)).style(dimStyle).left().build(), + helpArea + ) +} + +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" } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt index 2dea5ece..70a8d2d0 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt @@ -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)") - } else { - Text("stage: ${session.currentStage ?: "—"}") - Text("last output: ${session.lastOutput ?: "—"}") - } +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.from( + Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")), + Line.from(Span.raw("last output: ${session.lastOutput ?: "—"}")) + ) } + frame.renderWidget(Paragraph.builder().text(text).build(), area) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt index 8946c363..dd3ac8a4 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt @@ -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 { + 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 + ) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt index 4877a722..d37adae3 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt @@ -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 + "█") + ) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt deleted file mode 100644 index 3ecd3111..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt +++ /dev/null @@ -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) + "┤" -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt index 1330d27f..bcf49f1a 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt @@ -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, 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 { + 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" } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt index 3e5f5d52..5c7757b3 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt @@ -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 - } - "○ reconnecting (attempt ${state.connection.attempt}, retry in ${secondsRemaining}s)" + 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)") + ) } - state.connection.connected -> "● connected" - else -> "○ disconnected" + state.connection.connected -> listOf( + Span.styled("● ", Style.create().green()), + Span.raw("connected") + ) + else -> listOf( + Span.styled("○ ", Style.create().dim().gray()), + Span.raw("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) + ) + ) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt deleted file mode 100644 index bd032fbd..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt +++ /dev/null @@ -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)) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt deleted file mode 100644 index 368cd334..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt +++ /dev/null @@ -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 - } - } - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt new file mode 100644 index 00000000..d4ce129a --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt @@ -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 + } +} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt new file mode 100644 index 00000000..a6257a31 --- /dev/null +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt @@ -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)) + } +} diff --git a/docs/plans/2026-05-17-tui-refactor.progress.md b/docs/plans/2026-05-17-tui-refactor.progress.md index 1dfff345..38f428b8 100644 --- a/docs/plans/2026-05-17-tui-refactor.progress.md +++ b/docs/plans/2026-05-17-tui-refactor.progress.md @@ -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` and `connection: SharedFlow`; 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`. @@ -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>`, `scrollOffsetById: Map`. - 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 diff --git a/docs/specs/2026-05-17-tamboui-migration.md b/docs/specs/2026-05-17-tamboui-migration.md new file mode 100644 index 00000000..cce308d4 --- /dev/null +++ b/docs/specs/2026-05-17-tamboui-migration.md @@ -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.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 = 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>` + +- **TuiWsClient** (`apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt`): + - `val messages: SharedFlow` — emits server messages from the WebSocket stream. + - `val connection: SharedFlow` — 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)`. + - ` renderStatefulWidget(StatefulWidget, 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`. + 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`. Builder: + `.items(vararg String | vararg ListItem | List)`, `.block(Block)`, + `.style(Style)` (unselected), `.highlightStyle(Style)` (selected), + `.highlightSymbol(String|Line)`, `.scrollbarThumbStyle(Style)`, + `.scrollbarTrackStyle(Style)`, `.itemStyleResolver(BiFunction)`. +- `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)`, + `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` and + `SharedFlow`. 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().()`. 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.