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")
}
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 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<Effect>(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"
}
@@ -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)
}
@@ -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
}
"○ 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)
)
)
}
@@ -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))
}
}