feat(tui): redesign layout per spec v2 — 3-panel top row, 4-mode input bar, tamboui 0.3.0

- Layout: status bar (borderless), horizontal top row (sessions 30% / active 30% / tools 40%), router fill, input bar fixed
- Components: rewrote all 5 + added ToolsPanel; all accept TuiState directly
- InputBar: 4 modes (ROUTER/NAVIGATE/FILTER/STEER) with context-sensitive keybind hints
- RouterPanel: inline approval/event overlays as last-child text panels
- StatusBar: borderless row with model, providerType, session count, approval badge
- SessionList: manual column/row rendering, no list widget, overflow truncation
- tamboui bumped to 0.3.0
- TambouiKeyMapper: ctrl+key required for all keybinds, bare chars always
  go to input buffer.
- KeyResolver: Quit is ROUTER-only (not global); NavUp/NavDown handled in
  ROUTER as well as NAVIGATE; Filter in ROUTER maps to OpenFilter; CharInput
  in ROUTER returns null (semantic chars handled upstream by mapper).
This commit is contained in:
2026-05-20 00:53:45 +04:00
parent a855eaee45
commit ac5ee9c3e0
16 changed files with 446 additions and 375 deletions
+2
View File
@@ -67,3 +67,5 @@ bin/
/kls_database.db /kls_database.db
/code-analysis/ /code-analysis/
/.infra/sonarqube/docker-compose.yml /.infra/sonarqube/docker-compose.yml
/logs
log4j2.properties
+1 -1
View File
@@ -29,7 +29,7 @@ dependencies {
implementation "io.ktor:ktor-client-websockets:$ktor_version" implementation "io.ktor:ktor-client-websockets:$ktor_version"
implementation "io.ktor:ktor-client-logging:$ktor_version" implementation "io.ktor:ktor-client-logging:$ktor_version"
implementation platform('dev.tamboui:tamboui-bom:0.2.1-SNAPSHOT') implementation platform('dev.tamboui:tamboui-bom:0.3.0')
implementation 'dev.tamboui:tamboui-tui' implementation 'dev.tamboui:tamboui-tui'
implementation 'dev.tamboui:tamboui-widgets' implementation 'dev.tamboui:tamboui-widgets'
implementation 'dev.tamboui:tamboui-core' implementation 'dev.tamboui:tamboui-core'
@@ -10,6 +10,9 @@ sealed class KeyEvent {
object NavUp : KeyEvent() object NavUp : KeyEvent()
object NavDown : KeyEvent() object NavDown : KeyEvent()
object Filter : KeyEvent() object Filter : KeyEvent()
object Tab : KeyEvent()
object ToggleApprovalOverlay : KeyEvent()
object ToggleEventOverlay : KeyEvent()
object Enter : KeyEvent() object Enter : KeyEvent()
object Backspace : KeyEvent() object Backspace : KeyEvent()
object Escape : KeyEvent() object Escape : KeyEvent()
@@ -1,34 +1,26 @@
package com.correx.apps.tui package com.correx.apps.tui
import com.correx.apps.tui.components.activeSessionWidget import com.correx.apps.tui.components.activeSessionWidget
import com.correx.apps.tui.components.approvalPanelWidget
import com.correx.apps.tui.components.filteredSessions
import com.correx.apps.tui.components.inputBarWidget import com.correx.apps.tui.components.inputBarWidget
import com.correx.apps.tui.components.routerPanelWidget import com.correx.apps.tui.components.routerPanelWidget
import com.correx.apps.tui.components.sessionListWidget import com.correx.apps.tui.components.sessionListWidget
import com.correx.apps.tui.components.statusBarWidget import com.correx.apps.tui.components.statusBarWidget
import com.correx.apps.tui.components.toolsPanelWidget
import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.Action
import com.correx.apps.tui.input.KeyResolver import com.correx.apps.tui.input.KeyResolver
import com.correx.apps.tui.input.mapKey import com.correx.apps.tui.input.mapKey
import com.correx.apps.tui.reducer.EffectDispatcher import com.correx.apps.tui.reducer.EffectDispatcher
import com.correx.apps.tui.reducer.RootReducer 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.state.TuiState
import com.correx.apps.tui.ws.ConnectionEvent import com.correx.apps.tui.ws.ConnectionEvent
import com.correx.apps.tui.ws.TuiWsClient import com.correx.apps.tui.ws.TuiWsClient
import dev.tamboui.layout.Constraint import dev.tamboui.layout.Constraint
import dev.tamboui.layout.Layout import dev.tamboui.layout.Layout
import dev.tamboui.style.Style
import dev.tamboui.terminal.Frame import dev.tamboui.terminal.Frame
import dev.tamboui.tui.EventHandler import dev.tamboui.tui.EventHandler
import dev.tamboui.tui.Renderer import dev.tamboui.tui.Renderer
import dev.tamboui.tui.TuiRunner import dev.tamboui.tui.TuiRunner
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.list.ListState
import dev.tamboui.widgets.paragraph.Paragraph
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -36,14 +28,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
private const val DEFAULT_PORT = 8080 private const val DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 3 private const val STATUS_HEIGHT = 1
private const val ACTIVE_SESSION_HEIGHT = 5 private const val TOP_ROW_HEIGHT = 9
private const val APPROVAL_HEIGHT = 6 private const val INPUT_HEIGHT = 4
private const val INPUT_HEIGHT = 3
private const val ROUTER_HEIGHT = 5
private const val KEYBINDS_HEIGHT = 3
private const val KEYBINDS_TEXT = "n new c cancel a approve r reject s steer / filter q quit"
fun main(args: Array<String>) { fun main(args: Array<String>) {
val host = args.getOrElse(0) { "localhost" } val host = args.getOrElse(0) { "localhost" }
@@ -52,7 +39,6 @@ fun main(args: Array<String>) {
var state = TuiState() var state = TuiState()
val ws = TuiWsClient(host = host, port = port) val ws = TuiWsClient(host = host, port = port)
val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val listState = ListState()
TuiRunner.create().use { runner -> TuiRunner.create().use { runner ->
fun dispatch(action: Action) { fun dispatch(action: Action) {
@@ -91,66 +77,37 @@ fun main(args: Array<String>) {
true true
} }
val renderer = Renderer { frame -> render(frame, state, listState) } val renderer = Renderer { frame -> render(frame, state) }
runner.run(handler, renderer) runner.run(handler, renderer)
} }
} }
} }
private fun render(frame: Frame, state: TuiState, listState: ListState) { private fun render(frame: Frame, state: TuiState) {
val area = frame.area() val area = frame.area()
val hasInput = state.inputMode != InputMode.ROUTER
val hasApproval = state.approval.active != null
val constraints = buildList { val vertRects = Layout.vertical()
add(Constraint.length(STATUS_HEIGHT)) .constraints(
add(Constraint.fill()) Constraint.length(STATUS_HEIGHT),
add(Constraint.length(ACTIVE_SESSION_HEIGHT)) Constraint.length(TOP_ROW_HEIGHT),
add(Constraint.length(ROUTER_HEIGHT)) Constraint.fill(),
if (hasApproval) add(Constraint.length(APPROVAL_HEIGHT)) Constraint.length(INPUT_HEIGHT),
if (hasInput) add(Constraint.length(INPUT_HEIGHT)) )
add(Constraint.length(KEYBINDS_HEIGHT))
}
@Suppress("SpreadOperator")
val rects = Layout.vertical()
.constraints(*constraints.toTypedArray())
.split(area) .split(area)
var idx = 0 val topRects = Layout.horizontal()
.constraints(
Constraint.percentage(30),
Constraint.percentage(30),
Constraint.percentage(40),
)
.split(vertRects[1])
frame.renderWidget(statusBarWidget(state), rects[idx++]) frame.renderWidget(statusBarWidget(state), vertRects[0])
frame.renderWidget(sessionListWidget(state), topRects[0])
val filtered = filteredSessions(state.sessions) frame.renderWidget(activeSessionWidget(state), topRects[1])
val selectedSession = filtered.firstOrNull { it.id == state.sessions.selectedId } frame.renderWidget(toolsPanelWidget(state), topRects[2])
?: filtered.firstOrNull() frame.renderWidget(routerPanelWidget(state), vertRects[2])
frame.renderStatefulWidget(sessionListWidget(state.sessions, state.approval, listState), rects[idx++], listState) frame.renderWidget(inputBarWidget(state), vertRects[3])
frame.renderWidget(activeSessionWidget(selectedSession), rects[idx++])
frame.renderWidget(routerPanelWidget(selectedSession), rects[idx++])
if (hasApproval) {
frame.renderWidget(approvalPanelWidget(state.approval.active!!), rects[idx++])
}
if (hasInput) {
inputBarWidget(state.inputMode, state.inputBuffer)?.let { frame.renderWidget(it, rects[idx]) }
idx++
}
val keybindsBlock = Block.builder()
.title("keybinds")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
frame.renderWidget(
Paragraph.builder()
.text(KEYBINDS_TEXT)
.style(Style.create().dim().gray())
.block(keybindsBlock)
.build(),
rects[idx]
)
} }
@@ -1,6 +1,8 @@
package com.correx.apps.tui.components package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
import dev.tamboui.text.Text import dev.tamboui.text.Text
@@ -9,25 +11,43 @@ import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph import dev.tamboui.widgets.paragraph.Paragraph
fun activeSessionWidget(session: SessionSummary?): Paragraph { private const val LABEL_WIDTH = 8
val text = if (session == null) {
Text.from(Line.from(Span.raw("(no session selected)"))) fun activeSessionWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val session = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val lines = if (session == null) {
listOf(Line.from(Span.styled("no session selected", dimStyle)))
} else { } else {
val lines = buildList<Line> { buildList<Line> {
add(Line.from(Span.raw("stage: ${session.currentStage ?: "—"}"))) val stageVal = session.currentStageId ?: session.currentStage ?: ""
session.lastOutput?.let { add(Line.from(Span.raw("last output: $it"))) } add(labelRow("stage", Span.raw(stageVal)))
session.lastResponseText?.takeIf { it.isNotEmpty() }?.let { text -> add(labelRow("status", statusSpanFor(session)))
add(Line.from(Span.raw(""))) add(labelRow("model", Span.styled(state.currentModel ?: "", Style.create().cyan())))
add(Line.from(Span.raw("response:"))) session.nextStageId?.let { add(labelRow("next", Span.styled(it, dimStyle))) }
text.lineSequence().forEach { add(Line.from(Span.raw(it))) }
}
} }
Text.from(lines)
} }
val block = Block.builder() val block = Block.builder()
.title("active session") .title("active session")
.borders(Borders.ALL) .borders(Borders.ALL)
.borderType(BorderType.ROUNDED) .borderType(BorderType.ROUNDED)
.build() .build()
return Paragraph.builder().text(text).block(block).build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
private fun labelRow(label: String, value: Span): Line =
Line.from(Span.raw(label.padEnd(LABEL_WIDTH)), value)
private fun statusSpanFor(s: SessionSummary): Span {
val label = s.status.uppercase()
return when (s.status.lowercase()) {
"active", "running" -> Span.styled(label, Style.create().green())
"paused" -> Span.styled(label, Style.create().yellow())
"failed", "error" -> Span.styled(label, Style.create().red())
"completed", "done" -> Span.styled(label, Style.create().dim().gray())
else -> Span.raw(label)
}
} }
@@ -1,6 +1,7 @@
package com.correx.apps.tui.components package com.correx.apps.tui.components
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
@@ -10,22 +11,69 @@ import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph import dev.tamboui.widgets.paragraph.Paragraph
fun inputBarWidget(inputMode: InputMode, inputBuffer: String): Paragraph? { fun inputBarWidget(state: TuiState): Paragraph {
if (inputMode == InputMode.ROUTER) return null val dimStyle = Style.create().dim().gray()
val (promptText, promptStyle) = when (inputMode) { val hasApproval = state.approval.active != null
InputMode.ROUTER -> "" to Style.create() val hasSession = state.sessions.selectedId != null
InputMode.NAVIGATE -> "" to Style.create()
InputMode.FILTER -> "filter > " to Style.create().yellow() val sessionName = state.sessions.sessions
InputMode.STEER -> "steer > " to Style.create().magenta() .firstOrNull { it.id == state.sessions.selectedId }
?.name?.ifEmpty { null }
?: state.sessions.selectedId?.take(6)
?: "(no session)"
val (row1, row2) = when (state.inputMode) {
InputMode.ROUTER -> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Ask anything…", dimStyle))
}
val hints = buildList<String> {
add("$sessionName · router")
add("tab navigate")
if (hasApproval) { add("ctrl+a approve"); add("ctrl+r reject") }
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
cursor to Line.from(Span.styled(hints, dimStyle))
}
InputMode.NAVIGATE -> {
val row1Line = Line.from(Span.styled(" ↑↓ sessions enter select", dimStyle))
val hints = buildList<String> {
add("$sessionName · navigate")
add("tab router")
if (hasApproval) { add("ctrl+a approve"); add("ctrl+r reject") }
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
row1Line to Line.from(Span.styled(hints, dimStyle))
}
InputMode.FILTER -> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("/filter…", dimStyle))
}
cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle))
}
InputMode.STEER -> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Steer the session…", dimStyle))
}
cursor to Line.from(Span.styled("$sessionName · steering esc cancel enter send", dimStyle))
}
} }
val line = Line.from(
Span.styled(promptText, promptStyle),
Span.raw("${inputBuffer}_")
)
val block = Block.builder() val block = Block.builder()
.title("input") .title("")
.borders(Borders.ALL) .borders(Borders.ALL)
.borderType(BorderType.ROUNDED) .borderType(BorderType.ROUNDED)
.build() .build()
return Paragraph.builder().text(Text.from(line)).block(block).build()
return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build()
} }
@@ -1,6 +1,7 @@
package com.correx.apps.tui.components package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Color
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
@@ -10,15 +11,53 @@ import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph import dev.tamboui.widgets.paragraph.Paragraph
fun routerPanelWidget(session: SessionSummary?): Paragraph { fun routerPanelWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray() val dimStyle = Style.create().dim().gray()
val text = session?.lastResponseText?.takeIf { it.isNotEmpty() }?.let { responseText ->
Text.from(responseText.lineSequence().map { Line.from(Span.styled(it, dimStyle)) }.toList()) val mainLines = buildList<Line> {
} ?: Text.from(Line.from(Span.styled("(no response)", dimStyle))) if (!state.routerConnected) {
add(Line.from(Span.styled("not connected — epic 14", dimStyle)))
} else {
for (msg in state.routerMessages) {
add(Line.from(Span.raw(msg)))
}
}
}
val overlayLines: List<Line> = when {
state.approvalOverlayVisible && state.approval.active != null -> {
val a = state.approval.active
buildList {
add(Line.from(Span.styled("╭─ approval [ctrl+h to hide] ─────────────╮", Style.create().yellow())))
val tierLine = "│ ⚠ T${a.tier}${a.toolName ?: ""}${a.riskSummary.take(12)}... │"
add(Line.from(Span.styled(tierLine, Style.create().yellow())))
val previewLine = "${(a.preview ?: "").take(40)}… │"
add(Line.from(Span.styled(previewLine, dimStyle)))
add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().yellow())))
}
}
state.eventOverlayVisible -> {
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val events = selectedSession?.recentEvents ?: emptyList()
buildList {
add(Line.from(Span.styled("╭─ events [ctrl+e to hide] ───────────────╮", Style.create().magenta())))
for (ev in events) {
val row = "${ev.timestamp} ${ev.type} ${ev.detail}"
add(Line.from(Span.styled(row, dimStyle)))
}
add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().magenta())))
}
}
else -> emptyList()
}
val allLines = mainLines + overlayLines
val block = Block.builder() val block = Block.builder()
.title("router") .title("router")
.borders(Borders.ALL) .borders(Borders.ALL)
.borderType(BorderType.ROUNDED) .borderType(BorderType.ROUNDED)
.build() .build()
return Paragraph.builder().text(text).block(block).build()
return Paragraph.builder().text(Text.from(allLines)).block(block).build()
} }
@@ -1,41 +1,62 @@
package com.correx.apps.tui.components package com.correx.apps.tui.components
import com.correx.apps.tui.state.ApprovalState
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.list.ListItem import dev.tamboui.widgets.paragraph.Paragraph
import dev.tamboui.widgets.list.ListState
import dev.tamboui.widgets.list.ListWidget
private const val MS_PER_SEC = 1000L private const val MAX_VISIBLE = 7
private const val SEC_PER_MIN = 60L private const val NAME_MAX = 15
private const val SEC_PER_HOUR = 3600L
private const val ID_LEN = 6 private const val ID_LEN = 6
private const val STATUS_WIDTH = 8
fun sessionListWidget(sessions: SessionsState, approval: ApprovalState, listState: ListState): ListWidget { fun sessionListWidget(state: TuiState): Paragraph {
val filtered = filteredSessions(sessions) val dimStyle = Style.create().dim().gray()
val items = filtered.map { ListItem.from(formatSessionItem(it, approval)) } val sessions = filteredSessions(state.sessions)
val selIdx = filtered.indexOfFirst { it.id == sessions.selectedId }.let { if (it < 0) 0 else it } val visible = sessions.take(MAX_VISIBLE)
listState.select(selIdx) val overflow = sessions.size - MAX_VISIBLE
@Suppress("SpreadOperator")
return ListWidget.builder() val lines = buildList<Line> {
.items(*items.toTypedArray()) for (s in visible) {
.highlightStyle(Style.create().cyan().reversed()) val isSelected = s.id == state.sessions.selectedId
.highlightSymbol(" ") val prefix = if (isSelected) Span.styled("", Style.create().blue()) else Span.raw(" ")
.block( val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", dimStyle)
Block.builder() val statusStr = s.status.uppercase().padEnd(STATUS_WIDTH)
.title("session list") val statusSpan = statusSpan(s)
.borders(Borders.ALL) val rawName = s.name.ifEmpty { s.workflowId }
.borderType(BorderType.ROUNDED) val truncated = if (rawName.length > NAME_MAX) rawName.take(NAME_MAX - 1) + "" else rawName
.build() add(Line.from(prefix, idSpan, Span.raw(" "), statusSpan, Span.raw(" $truncated")))
) }
if (overflow > 0) {
add(Line.from(Span.styled("$overflow more —", dimStyle)))
}
}
val block = Block.builder()
.title("sessions")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build() .build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
private fun statusSpan(s: SessionSummary): Span {
val label = s.status.uppercase().padEnd(STATUS_WIDTH)
return when (s.status.lowercase()) {
"active", "running" -> Span.styled(label, Style.create().green())
"paused" -> Span.styled(label, Style.create().yellow())
"failed", "error" -> Span.styled(label, Style.create().red())
"completed", "done" -> Span.styled(label, Style.create().dim().gray())
else -> Span.raw(label)
}
} }
fun filteredSessions(sessions: SessionsState): List<SessionSummary> { fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
@@ -43,39 +64,3 @@ fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
return if (f.isEmpty()) sessions.sessions return if (f.isEmpty()) sessions.sessions
else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) } else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) }
} }
fun formatSessionItem(s: SessionSummary, approval: ApprovalState): Line {
val dimStyle = Style.create().dim().gray()
val statusSpan = when (s.status.lowercase()) {
"running", "active" -> Span.styled(s.status.uppercase(), Style.create().green())
"error", "failed" -> Span.styled(s.status.uppercase(), Style.create().red())
"paused" -> Span.styled(s.status.uppercase(), Style.create().yellow())
"completed", "done" -> Span.styled(s.status.uppercase(), dimStyle)
"idle" -> Span.styled(s.status.uppercase(), Style.create().cyan())
else -> Span.raw(s.status.uppercase())
}
val stage = s.currentStage?.let { " stage $it" } ?: ""
val awaitingApproval = approval.active?.sessionId == s.id
val trailing = if (awaitingApproval) {
Span.styled(" awaiting approval", Style.create().yellow())
} else {
Span.styled(" ${formatAgo(s.lastEventAt)}", dimStyle)
}
return Line.from(
Span.styled("[${s.id.take(ID_LEN)}]", dimStyle),
Span.raw(" \"${s.workflowId}\""),
Span.raw(" "),
statusSpan,
Span.raw(stage),
trailing
)
}
fun formatAgo(epochMs: Long): String {
val secs = (System.currentTimeMillis() - epochMs) / MS_PER_SEC
return when {
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,56 +1,48 @@
package com.correx.apps.tui.components package com.correx.apps.tui.components
import com.correx.apps.tui.state.ProviderType
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
import dev.tamboui.text.Text 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 import dev.tamboui.widgets.paragraph.Paragraph
private const val MS_PER_SEC = 1000L
fun statusBarWidget(state: TuiState): Paragraph { fun statusBarWidget(state: TuiState): Paragraph {
val connSpans = when { val dimStyle = Style.create().dim().gray()
state.connection.reconnecting -> { val sep = Span.styled("", dimStyle)
val rem = state.connection.nextRetryAtMs?.let {
maxOf(0, (it - System.currentTimeMillis()) / MS_PER_SEC) val connSpan = if (state.connection.connected) {
} ?: 0 Span.styled("", Style.create().green())
listOf(
Span.styled("", Style.create().yellow()),
Span.raw(" reconnecting (attempt ${state.connection.attempt}, retry in ${rem}s)")
)
}
state.connection.connected -> listOf(
Span.styled("", Style.create().green()),
Span.raw(" connected")
)
else -> listOf(
Span.styled("", Style.create().dim().gray()),
Span.raw(" disconnected")
)
}
val prov = if (state.provider.id.isNotEmpty()) {
"${state.provider.id} (${state.provider.status})"
} else { } else {
state.provider.status Span.styled("", Style.create().dim().red())
} }
val count = state.sessions.sessions.size val connLabel = if (state.connection.connected) Span.raw(" connected") else Span.raw(" disconnected")
val sep = Span.raw("")
val line = Line.from( val hostSpan = Span.styled("localhost:8080", dimStyle)
connSpans + listOf(
sep, val modelLabel = (state.currentModel ?: "(no model)") +
Span.raw("provider: $prov"), if (state.providerType == ProviderType.LOCAL) " (local)" else " (remote)"
sep, val modelSpan = Span.styled(modelLabel, Style.create().cyan())
Span.raw("sessions: $count")
) val sessionsSpan = Span.styled("${state.sessions.sessions.size} sessions", dimStyle)
)
val block = Block.builder() val spans = buildList<Span> {
.title("status bar") add(connSpan)
.borders(Borders.ALL) add(connLabel)
.borderType(BorderType.ROUNDED) add(sep)
add(hostSpan)
add(sep)
add(modelSpan)
add(sep)
add(sessionsSpan)
if (state.approval.active != null) {
add(sep)
add(Span.styled("[⚠ approval]", Style.create().yellow()))
}
}
return Paragraph.builder()
.text(Text.from(Line.from(spans)))
.build() .build()
return Paragraph.builder().text(Text.from(line)).block(block).build()
} }
@@ -0,0 +1,47 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.ToolDisplayStatus
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
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
private const val ARGS_MAX = 30
fun toolsPanelWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val amberStyle = Style.create().yellow()
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val tools = selectedSession?.tools ?: emptyList()
val lines = buildList<Line> {
for (tool in tools) {
val warn = tool.tier >= 2 || tool.status == ToolDisplayStatus.REQUESTED
val nameSpans = buildList<Span> {
add(Span.raw(tool.name))
if (warn) {
add(Span.raw(" "))
add(Span.styled("", amberStyle))
}
}
add(Line.from(nameSpans))
tool.argsPreview?.let { args ->
val truncated = if (args.length > ARGS_MAX) args.take(ARGS_MAX - 1) + "" else args
add(Line.from(Span.styled(" $truncated", dimStyle)))
}
}
}
val block = Block.builder()
.title("tools")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
@@ -23,4 +23,5 @@ sealed interface Action {
data object ToggleApprovalOverlay : Action data object ToggleApprovalOverlay : Action
data object ToggleEventOverlay : Action data object ToggleEventOverlay : Action
data object EnterSteer : Action data object EnterSteer : Action
data object CycleMode : Action
} }
@@ -4,13 +4,23 @@ import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
object KeyResolver { object KeyResolver {
fun resolve(key: KeyEvent, mode: InputMode, inputText: String): Action? = when (mode) { fun resolve(key: KeyEvent, mode: InputMode, inputText: String): Action? {
InputMode.ROUTER -> resolveRouterMode(key) val global = resolveGlobal(key)
InputMode.NAVIGATE -> resolveNavigateMode(key) if (global != null) return global
InputMode.FILTER, InputMode.STEER -> resolveTextMode(key, inputText) return when (mode) {
InputMode.ROUTER -> resolveRouterMode(key, inputText)
InputMode.NAVIGATE -> resolveNavigateMode(key)
InputMode.FILTER, InputMode.STEER -> resolveTextMode(key, inputText)
}
} }
private fun resolveRouterMode(key: KeyEvent): Action? = when (key) { private fun resolveGlobal(key: KeyEvent): Action? = when (key) {
KeyEvent.ToggleApprovalOverlay -> Action.ToggleApprovalOverlay
KeyEvent.ToggleEventOverlay -> Action.ToggleEventOverlay
else -> null
}
private fun resolveRouterMode(key: KeyEvent, inputText: String): Action? = when (key) {
KeyEvent.Quit -> Action.Quit KeyEvent.Quit -> Action.Quit
KeyEvent.NewSession -> Action.OpenNewSessionPrompt KeyEvent.NewSession -> Action.OpenNewSessionPrompt
KeyEvent.Cancel -> Action.CancelSelectedSession KeyEvent.Cancel -> Action.CancelSelectedSession
@@ -19,18 +29,24 @@ object KeyResolver {
KeyEvent.Steer -> Action.OpenSteeringPrompt KeyEvent.Steer -> Action.OpenSteeringPrompt
KeyEvent.NavUp -> Action.NavigateUp KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Filter -> Action.OpenFilter KeyEvent.Filter -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null else -> null
} }
private fun resolveNavigateMode(key: KeyEvent): Action? = when (key) { private fun resolveNavigateMode(key: KeyEvent): Action? = when (key) {
KeyEvent.NavUp -> Action.NavigateUp KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Enter -> Action.SubmitInput
KeyEvent.Filter -> Action.CycleMode
KeyEvent.Escape -> Action.CancelInput KeyEvent.Escape -> Action.CancelInput
else -> null else -> null
} }
private fun resolveTextMode(key: KeyEvent, inputText: String): Action? = when (key) { private fun resolveTextMode(key: KeyEvent, inputText: String): Action? = when (key) {
KeyEvent.Filter -> Action.CycleMode
KeyEvent.Escape -> Action.CancelInput KeyEvent.Escape -> Action.CancelInput
KeyEvent.Backspace -> Action.Backspace KeyEvent.Backspace -> Action.Backspace
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
@@ -8,7 +8,20 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
@Suppress("CyclomaticComplexMethod", "ReturnCount") @Suppress("CyclomaticComplexMethod", "ReturnCount")
fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? { fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? {
if (event.isCtrlC()) return KeyEvent.Quit if (event.isCtrlC()) return KeyEvent.Quit
if (event.hasCtrl() || event.hasAlt()) return null if (event.hasAlt()) return null
if (event.hasCtrl()) {
return when (event.character()) {
'q' -> KeyEvent.Quit
'n' -> KeyEvent.NewSession
'c' -> KeyEvent.Cancel
'a' -> KeyEvent.Approve
'r' -> KeyEvent.Reject
's' -> KeyEvent.Steer
'h' -> KeyEvent.ToggleApprovalOverlay
'e' -> KeyEvent.ToggleEventOverlay
else -> null
}
}
return when (event.code()) { return when (event.code()) {
KeyCode.UP -> KeyEvent.NavUp KeyCode.UP -> KeyEvent.NavUp
KeyCode.DOWN -> KeyEvent.NavDown KeyCode.DOWN -> KeyEvent.NavDown
@@ -18,18 +31,7 @@ fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? {
KeyCode.TAB -> KeyEvent.Filter KeyCode.TAB -> KeyEvent.Filter
KeyCode.CHAR -> { KeyCode.CHAR -> {
val ch = event.character() val ch = event.character()
if (ch.isISOControl()) return null if (ch.isISOControl()) null else KeyEvent.CharInput(ch)
if (mode != InputMode.ROUTER) 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 else -> null
} }
@@ -13,6 +13,15 @@ object InputReducer {
state: TuiState, state: TuiState,
action: Action, action: Action,
): Pair<TuiState, List<Effect>> = when (action) { ): Pair<TuiState, List<Effect>> = when (action) {
is Action.CycleMode -> state.copy(
inputMode = when (state.inputMode) {
InputMode.ROUTER -> InputMode.NAVIGATE
InputMode.NAVIGATE -> InputMode.FILTER
InputMode.FILTER -> InputMode.ROUTER
InputMode.STEER -> InputMode.ROUTER
},
inputBuffer = "",
) to emptyList()
is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
is Action.OpenSteeringPrompt -> if (state.approval.active != null) { is Action.OpenSteeringPrompt -> if (state.approval.active != null) {
state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
@@ -98,9 +98,9 @@ class KeyResolverTest {
} }
@Test @Test
fun `Filter in ROUTER mode resolves to OpenFilter`() { fun `Filter in ROUTER mode cycles to NAVIGATE`() {
val result = KeyResolver.resolve(KeyEvent.Filter, InputMode.ROUTER, "") val result = KeyResolver.resolve(KeyEvent.Filter, InputMode.ROUTER, "")
assertEquals(Action.OpenFilter, result) assertEquals(Action.CycleMode, result)
} }
@Test @Test
@@ -110,8 +110,8 @@ class KeyResolverTest {
} }
@Test @Test
fun `CharInput in ROUTER mode resolves to null`() { fun `CharInput in ROUTER mode resolves to AppendChar`() {
val result = KeyResolver.resolve(KeyEvent.CharInput('x'), InputMode.ROUTER, "") val result = KeyResolver.resolve(KeyEvent.CharInput('x'), InputMode.ROUTER, "")
assertNull(result) assertEquals(Action.AppendChar('x'), result)
} }
} }
@@ -15,206 +15,156 @@ class TambouiKeyMapperTest {
@Test @Test
fun `ctrl-c maps to Quit in InputMode ROUTER`() { fun `ctrl-c maps to Quit in InputMode ROUTER`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.ROUTER))
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.ROUTER))
} }
@Test @Test
fun `ctrl-c maps to Quit in InputMode FILTER`() { fun `ctrl-c maps to Quit in InputMode FILTER`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.FILTER))
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.FILTER))
} }
@Test @Test
fun `ctrl-c maps to Quit in InputMode NAVIGATE`() { fun `ctrl-c maps to Quit in InputMode NAVIGATE`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.NAVIGATE))
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.NAVIGATE))
} }
@Test @Test
fun `ctrl-c maps to Quit in InputMode STEER`() { fun `ctrl-c maps to Quit in InputMode STEER`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.STEER))
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.STEER))
} }
// --- Other Ctrl/Alt → null --- // --- Ctrl+key → keybind events ---
@Test @Test
fun `ctrl modifier non-ctrlC maps to null`() { fun `ctrl-q maps to Quit`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code), InputMode.ROUTER))
assertNull(mapKey(event, InputMode.ROUTER))
} }
@Test
fun `ctrl-a maps to Approve`() {
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code), InputMode.ROUTER))
}
@Test
fun `ctrl-r maps to Reject`() {
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'r'.code), InputMode.ROUTER))
}
@Test
fun `ctrl-n maps to NewSession`() {
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code), InputMode.ROUTER))
}
@Test
fun `ctrl-s maps to Steer`() {
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code), InputMode.ROUTER))
}
@Test
fun `ctrl-h maps to ToggleApprovalOverlay`() {
assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code), InputMode.ROUTER))
}
@Test
fun `ctrl-e maps to ToggleEventOverlay`() {
assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code), InputMode.ROUTER))
}
@Test
fun `ctrl+unbound key maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'z'.code), InputMode.ROUTER))
}
// --- Alt always → null ---
@Test @Test
fun `alt modifier maps to null`() { fun `alt modifier maps to null`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code) assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code), InputMode.ROUTER))
assertNull(mapKey(event, InputMode.ROUTER))
} }
// --- KeyCode mappings in InputMode.ROUTER --- // --- Bare chars always → CharInput (never trigger keybinds) ---
@Test @Test
fun `UP maps to NavUp in InputMode ROUTER`() { fun `bare a maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.ROUTER)) assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'), InputMode.ROUTER))
} }
@Test @Test
fun `DOWN maps to NavDown in InputMode ROUTER`() { fun `bare q maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.ROUTER)) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.ROUTER))
} }
@Test @Test
fun `ENTER maps to Enter in InputMode ROUTER`() { fun `bare s maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.ROUTER)) assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'), InputMode.ROUTER))
} }
@Test @Test
fun `ESCAPE maps to Escape in InputMode ROUTER`() { fun `bare x maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.ROUTER))
}
@Test
fun `BACKSPACE maps to Backspace in InputMode ROUTER`() {
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.ROUTER))
}
@Test
fun `TAB maps to Filter in InputMode ROUTER`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.ROUTER))
}
// --- 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.ROUTER ---
@Test
fun `q maps to Quit in InputMode ROUTER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent.ofChar('q'), InputMode.ROUTER))
}
@Test
fun `n maps to NewSession in InputMode ROUTER`() {
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent.ofChar('n'), InputMode.ROUTER))
}
@Test
fun `c maps to Cancel in InputMode ROUTER`() {
assertEquals(KeyEvent.Cancel, mapKey(TambouiKeyEvent.ofChar('c'), InputMode.ROUTER))
}
@Test
fun `a maps to Approve in InputMode ROUTER`() {
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent.ofChar('a'), InputMode.ROUTER))
}
@Test
fun `r maps to Reject in InputMode ROUTER`() {
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent.ofChar('r'), InputMode.ROUTER))
}
@Test
fun `s maps to Steer in InputMode ROUTER`() {
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent.ofChar('s'), InputMode.ROUTER))
}
@Test
fun `slash maps to Filter in InputMode ROUTER`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofChar('/'), InputMode.ROUTER))
}
@Test
fun `other char maps to CharInput in InputMode ROUTER`() {
assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'), InputMode.ROUTER)) assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'), InputMode.ROUTER))
} }
// --- Regression: semantic chars map to CharInput in non-ROUTER modes ---
@Test @Test
fun `q maps to CharInput in InputMode NAVIGATE`() { fun `bare q maps to CharInput in FILTER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.NAVIGATE)) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.FILTER))
} }
@Test @Test
fun `n maps to CharInput in InputMode NAVIGATE`() { fun `bare q maps to CharInput in STEER`() {
assertEquals(KeyEvent.CharInput('n'), mapKey(TambouiKeyEvent.ofChar('n'), InputMode.NAVIGATE))
}
@Test
fun `c maps to CharInput in InputMode NAVIGATE`() {
assertEquals(KeyEvent.CharInput('c'), mapKey(TambouiKeyEvent.ofChar('c'), InputMode.NAVIGATE))
}
@Test
fun `a maps to CharInput in InputMode NAVIGATE`() {
assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'), InputMode.NAVIGATE))
}
@Test
fun `r maps to CharInput in InputMode NAVIGATE`() {
assertEquals(KeyEvent.CharInput('r'), mapKey(TambouiKeyEvent.ofChar('r'), InputMode.NAVIGATE))
}
@Test
fun `s maps to CharInput in InputMode NAVIGATE`() {
assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'), InputMode.NAVIGATE))
}
@Test
fun `slash maps to CharInput in InputMode NAVIGATE`() {
assertEquals(KeyEvent.CharInput('/'), mapKey(TambouiKeyEvent.ofChar('/'), InputMode.NAVIGATE))
}
@Test
fun `q maps to CharInput in InputMode STEER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.STEER)) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.STEER))
} }
@Test @Test
fun `q maps to CharInput in InputMode FILTER`() { fun `bare q maps to CharInput in NAVIGATE`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.FILTER)) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.NAVIGATE))
} }
// --- ISO control chars in CHAR event → null --- // --- KeyCode mappings ---
@Test
fun `UP maps to NavUp`() {
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.ROUTER))
}
@Test
fun `DOWN maps to NavDown`() {
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.ROUTER))
}
@Test
fun `ENTER maps to Enter`() {
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.ROUTER))
}
@Test
fun `ESCAPE maps to Escape`() {
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.ROUTER))
}
@Test
fun `BACKSPACE maps to Backspace`() {
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.ROUTER))
}
@Test
fun `TAB maps to Filter`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.ROUTER))
}
@Test
fun `TAB maps to Filter in FILTER mode`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.FILTER))
}
// --- ISO control chars → null ---
@Test @Test
fun `ESC codepoint as CHAR event maps to null`() { fun `ESC codepoint as CHAR event maps to null`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B) assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B), InputMode.ROUTER))
assertNull(mapKey(event, InputMode.ROUTER))
} }
@Test @Test
fun `NUL codepoint as CHAR event maps to null`() { fun `NUL codepoint as CHAR event maps to null`() {
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00) assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00), InputMode.ROUTER))
assertNull(mapKey(event, InputMode.ROUTER))
} }
} }