diff --git a/.gitignore b/.gitignore index c9a30e6b..0589c94e 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,5 @@ bin/ /kls_database.db /code-analysis/ /.infra/sonarqube/docker-compose.yml +/logs +log4j2.properties \ No newline at end of file diff --git a/apps/tui/build.gradle b/apps/tui/build.gradle index 15970123..18a8a017 100644 --- a/apps/tui/build.gradle +++ b/apps/tui/build.gradle @@ -29,7 +29,7 @@ dependencies { implementation "io.ktor:ktor-client-websockets:$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-widgets' implementation 'dev.tamboui:tamboui-core' diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt index c421efb5..0ce828c0 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt @@ -10,6 +10,9 @@ sealed class KeyEvent { object NavUp : KeyEvent() object NavDown : KeyEvent() object Filter : KeyEvent() + object Tab : KeyEvent() + object ToggleApprovalOverlay : KeyEvent() + object ToggleEventOverlay : KeyEvent() object Enter : KeyEvent() object Backspace : KeyEvent() object Escape : KeyEvent() 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 a941b45e..5a67895b 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,34 +1,26 @@ package com.correx.apps.tui 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.routerPanelWidget import com.correx.apps.tui.components.sessionListWidget 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.KeyResolver import com.correx.apps.tui.input.mapKey import com.correx.apps.tui.reducer.EffectDispatcher 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 dev.tamboui.layout.Constraint import dev.tamboui.layout.Layout -import dev.tamboui.style.Style import dev.tamboui.terminal.Frame 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.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.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -36,14 +28,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking private const val DEFAULT_PORT = 8080 -private const val STATUS_HEIGHT = 3 -private const val ACTIVE_SESSION_HEIGHT = 5 -private const val APPROVAL_HEIGHT = 6 -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" +private const val STATUS_HEIGHT = 1 +private const val TOP_ROW_HEIGHT = 9 +private const val INPUT_HEIGHT = 4 fun main(args: Array) { val host = args.getOrElse(0) { "localhost" } @@ -52,7 +39,6 @@ fun main(args: Array) { var state = TuiState() val ws = TuiWsClient(host = host, port = port) val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - val listState = ListState() TuiRunner.create().use { runner -> fun dispatch(action: Action) { @@ -91,66 +77,37 @@ fun main(args: Array) { true } - val renderer = Renderer { frame -> render(frame, state, listState) } + val renderer = Renderer { frame -> render(frame, state) } 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 hasInput = state.inputMode != InputMode.ROUTER - val hasApproval = state.approval.active != null - val constraints = buildList { - add(Constraint.length(STATUS_HEIGHT)) - add(Constraint.fill()) - add(Constraint.length(ACTIVE_SESSION_HEIGHT)) - add(Constraint.length(ROUTER_HEIGHT)) - if (hasApproval) add(Constraint.length(APPROVAL_HEIGHT)) - if (hasInput) add(Constraint.length(INPUT_HEIGHT)) - add(Constraint.length(KEYBINDS_HEIGHT)) - } - - @Suppress("SpreadOperator") - val rects = Layout.vertical() - .constraints(*constraints.toTypedArray()) + val vertRects = Layout.vertical() + .constraints( + Constraint.length(STATUS_HEIGHT), + Constraint.length(TOP_ROW_HEIGHT), + Constraint.fill(), + Constraint.length(INPUT_HEIGHT), + ) .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++]) - - val filtered = filteredSessions(state.sessions) - val selectedSession = filtered.firstOrNull { it.id == state.sessions.selectedId } - ?: filtered.firstOrNull() - frame.renderStatefulWidget(sessionListWidget(state.sessions, state.approval, listState), rects[idx++], listState) - - 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] - ) + frame.renderWidget(statusBarWidget(state), vertRects[0]) + frame.renderWidget(sessionListWidget(state), topRects[0]) + frame.renderWidget(activeSessionWidget(state), topRects[1]) + frame.renderWidget(toolsPanelWidget(state), topRects[2]) + frame.renderWidget(routerPanelWidget(state), vertRects[2]) + frame.renderWidget(inputBarWidget(state), vertRects[3]) } 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 93bda4e8..9a723dcb 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,6 +1,8 @@ package com.correx.apps.tui.components 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.Span 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.paragraph.Paragraph -fun activeSessionWidget(session: SessionSummary?): Paragraph { - val text = if (session == null) { - Text.from(Line.from(Span.raw("(no session selected)"))) +private const val LABEL_WIDTH = 8 + +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 { - val lines = buildList { - add(Line.from(Span.raw("stage: ${session.currentStage ?: "—"}"))) - session.lastOutput?.let { add(Line.from(Span.raw("last output: $it"))) } - session.lastResponseText?.takeIf { it.isNotEmpty() }?.let { text -> - add(Line.from(Span.raw(""))) - add(Line.from(Span.raw("response:"))) - text.lineSequence().forEach { add(Line.from(Span.raw(it))) } - } + buildList { + val stageVal = session.currentStageId ?: session.currentStage ?: "—" + add(labelRow("stage", Span.raw(stageVal))) + add(labelRow("status", statusSpanFor(session))) + add(labelRow("model", Span.styled(state.currentModel ?: "—", Style.create().cyan()))) + session.nextStageId?.let { add(labelRow("next", Span.styled(it, dimStyle))) } } - Text.from(lines) } + val block = Block.builder() .title("active session") .borders(Borders.ALL) .borderType(BorderType.ROUNDED) .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) + } } 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 575a0e59..f4b63c97 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,6 +1,7 @@ package com.correx.apps.tui.components import com.correx.apps.tui.state.InputMode +import com.correx.apps.tui.state.TuiState import dev.tamboui.style.Style import dev.tamboui.text.Line 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.paragraph.Paragraph -fun inputBarWidget(inputMode: InputMode, inputBuffer: String): Paragraph? { - if (inputMode == InputMode.ROUTER) return null - val (promptText, promptStyle) = when (inputMode) { - InputMode.ROUTER -> "" to Style.create() - InputMode.NAVIGATE -> "" to Style.create() - InputMode.FILTER -> "filter > " to Style.create().yellow() - InputMode.STEER -> "steer > " to Style.create().magenta() +fun inputBarWidget(state: TuiState): Paragraph { + val dimStyle = Style.create().dim().gray() + val hasApproval = state.approval.active != null + val hasSession = state.sessions.selectedId != null + + val sessionName = state.sessions.sessions + .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 { + 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 { + 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() - .title("input") + .title("") .borders(Borders.ALL) .borderType(BorderType.ROUNDED) .build() - return Paragraph.builder().text(Text.from(line)).block(block).build() + + return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt index 7c4b6c8e..e1ea8cf7 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt @@ -1,6 +1,7 @@ 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.text.Line 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.paragraph.Paragraph -fun routerPanelWidget(session: SessionSummary?): Paragraph { +fun routerPanelWidget(state: TuiState): Paragraph { 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()) - } ?: Text.from(Line.from(Span.styled("(no response)", dimStyle))) + + val mainLines = buildList { + 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 = 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() .title("router") .borders(Borders.ALL) .borderType(BorderType.ROUNDED) .build() - return Paragraph.builder().text(text).block(block).build() + + return Paragraph.builder().text(Text.from(allLines)).block(block).build() } 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 c2eecf64..63de0db9 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,41 +1,62 @@ 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.SessionsState +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.list.ListItem -import dev.tamboui.widgets.list.ListState -import dev.tamboui.widgets.list.ListWidget +import dev.tamboui.widgets.paragraph.Paragraph -private const val MS_PER_SEC = 1000L -private const val SEC_PER_MIN = 60L -private const val SEC_PER_HOUR = 3600L +private const val MAX_VISIBLE = 7 +private const val NAME_MAX = 15 private const val ID_LEN = 6 +private const val STATUS_WIDTH = 8 -fun sessionListWidget(sessions: SessionsState, approval: ApprovalState, listState: ListState): ListWidget { - val filtered = filteredSessions(sessions) - val items = filtered.map { ListItem.from(formatSessionItem(it, approval)) } - 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("session list") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - ) +fun sessionListWidget(state: TuiState): Paragraph { + val dimStyle = Style.create().dim().gray() + val sessions = filteredSessions(state.sessions) + val visible = sessions.take(MAX_VISIBLE) + val overflow = sessions.size - MAX_VISIBLE + + val lines = buildList { + for (s in visible) { + val isSelected = s.id == state.sessions.selectedId + val prefix = if (isSelected) Span.styled("▶ ", Style.create().blue()) else Span.raw(" ") + val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", dimStyle) + val statusStr = s.status.uppercase().padEnd(STATUS_WIDTH) + val statusSpan = statusSpan(s) + val rawName = s.name.ifEmpty { s.workflowId } + val truncated = if (rawName.length > NAME_MAX) rawName.take(NAME_MAX - 1) + "…" else rawName + 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() + + 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 { @@ -43,39 +64,3 @@ fun filteredSessions(sessions: SessionsState): List { return if (f.isEmpty()) sessions.sessions 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" - } -} 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 dfae8229..069139c2 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,56 +1,48 @@ package com.correx.apps.tui.components +import com.correx.apps.tui.state.ProviderType 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 MS_PER_SEC = 1000L - fun statusBarWidget(state: TuiState): Paragraph { - val connSpans = when { - state.connection.reconnecting -> { - 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 -> 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})" + val dimStyle = Style.create().dim().gray() + val sep = Span.styled(" │ ", dimStyle) + + val connSpan = if (state.connection.connected) { + Span.styled("●", Style.create().green()) } else { - state.provider.status + Span.styled("○", Style.create().dim().red()) } - val count = state.sessions.sessions.size - val sep = Span.raw(" │ ") - val line = Line.from( - connSpans + listOf( - sep, - Span.raw("provider: $prov"), - sep, - Span.raw("sessions: $count") - ) - ) - val block = Block.builder() - .title("status bar") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) + val connLabel = if (state.connection.connected) Span.raw(" connected") else Span.raw(" disconnected") + + val hostSpan = Span.styled("localhost:8080", dimStyle) + + val modelLabel = (state.currentModel ?: "(no model)") + + if (state.providerType == ProviderType.LOCAL) " (local)" else " (remote)" + val modelSpan = Span.styled(modelLabel, Style.create().cyan()) + + val sessionsSpan = Span.styled("${state.sessions.sessions.size} sessions", dimStyle) + + val spans = buildList { + add(connSpan) + add(connLabel) + 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() - return Paragraph.builder().text(Text.from(line)).block(block).build() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ToolsPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ToolsPanel.kt new file mode 100644 index 00000000..1566c87f --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ToolsPanel.kt @@ -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 { + for (tool in tools) { + val warn = tool.tier >= 2 || tool.status == ToolDisplayStatus.REQUESTED + val nameSpans = buildList { + 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() +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt index 0276197b..b3f46767 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt @@ -23,4 +23,5 @@ sealed interface Action { data object ToggleApprovalOverlay : Action data object ToggleEventOverlay : Action data object EnterSteer : Action + data object CycleMode : Action } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt index dac18d27..37466b5a 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt @@ -4,13 +4,23 @@ import com.correx.apps.tui.KeyEvent import com.correx.apps.tui.state.InputMode object KeyResolver { - fun resolve(key: KeyEvent, mode: InputMode, inputText: String): Action? = when (mode) { - InputMode.ROUTER -> resolveRouterMode(key) - InputMode.NAVIGATE -> resolveNavigateMode(key) - InputMode.FILTER, InputMode.STEER -> resolveTextMode(key, inputText) + fun resolve(key: KeyEvent, mode: InputMode, inputText: String): Action? { + val global = resolveGlobal(key) + if (global != null) return global + 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.NewSession -> Action.OpenNewSessionPrompt KeyEvent.Cancel -> Action.CancelSelectedSession @@ -19,18 +29,24 @@ object KeyResolver { KeyEvent.Steer -> Action.OpenSteeringPrompt KeyEvent.NavUp -> Action.NavigateUp 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 } private fun resolveNavigateMode(key: KeyEvent): Action? = when (key) { KeyEvent.NavUp -> Action.NavigateUp KeyEvent.NavDown -> Action.NavigateDown + KeyEvent.Enter -> Action.SubmitInput + KeyEvent.Filter -> Action.CycleMode KeyEvent.Escape -> Action.CancelInput else -> null } private fun resolveTextMode(key: KeyEvent, inputText: String): Action? = when (key) { + KeyEvent.Filter -> Action.CycleMode KeyEvent.Escape -> Action.CancelInput KeyEvent.Backspace -> Action.Backspace KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput 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 index 9015e907..5d6c9675 100644 --- 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 @@ -8,7 +8,20 @@ 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 + 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()) { KeyCode.UP -> KeyEvent.NavUp KeyCode.DOWN -> KeyEvent.NavDown @@ -18,18 +31,7 @@ fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? { KeyCode.TAB -> KeyEvent.Filter KeyCode.CHAR -> { val ch = event.character() - if (ch.isISOControl()) return null - 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) - } + if (ch.isISOControl()) null else KeyEvent.CharInput(ch) } else -> null } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt index fbd56f4b..fb5b2e67 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt @@ -13,6 +13,15 @@ object InputReducer { state: TuiState, action: Action, ): Pair> = 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.OpenSteeringPrompt -> if (state.approval.active != null) { state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt index 8447ef49..fea7a36e 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt @@ -98,9 +98,9 @@ class KeyResolverTest { } @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, "") - assertEquals(Action.OpenFilter, result) + assertEquals(Action.CycleMode, result) } @Test @@ -110,8 +110,8 @@ class KeyResolverTest { } @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, "") - assertNull(result) + assertEquals(Action.AppendChar('x'), result) } } 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 index 4a7defc4..e844cd6a 100644 --- 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 @@ -15,206 +15,156 @@ class TambouiKeyMapperTest { @Test fun `ctrl-c maps to Quit in InputMode ROUTER`() { - val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code) - assertEquals(KeyEvent.Quit, mapKey(event, InputMode.ROUTER)) + assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.ROUTER)) } @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)) + assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.FILTER)) } @Test fun `ctrl-c maps to Quit in InputMode NAVIGATE`() { - val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code) - assertEquals(KeyEvent.Quit, mapKey(event, InputMode.NAVIGATE)) + assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.NAVIGATE)) } @Test fun `ctrl-c maps to Quit in InputMode STEER`() { - val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code) - assertEquals(KeyEvent.Quit, mapKey(event, InputMode.STEER)) + assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.STEER)) } - // --- Other Ctrl/Alt → null --- + // --- Ctrl+key → keybind events --- @Test - fun `ctrl modifier non-ctrlC maps to null`() { - val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code) - assertNull(mapKey(event, InputMode.ROUTER)) + fun `ctrl-q maps to Quit`() { + assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code), 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 fun `alt modifier maps to null`() { - val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code) - assertNull(mapKey(event, InputMode.ROUTER)) + assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code), InputMode.ROUTER)) } - // --- KeyCode mappings in InputMode.ROUTER --- + // --- Bare chars always → CharInput (never trigger keybinds) --- @Test - fun `UP maps to NavUp in InputMode ROUTER`() { - assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.ROUTER)) + fun `bare a maps to CharInput in ROUTER`() { + assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'), InputMode.ROUTER)) } @Test - fun `DOWN maps to NavDown in InputMode ROUTER`() { - assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.ROUTER)) + fun `bare q maps to CharInput in ROUTER`() { + assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.ROUTER)) } @Test - fun `ENTER maps to Enter in InputMode ROUTER`() { - assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.ROUTER)) + fun `bare s maps to CharInput in ROUTER`() { + assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'), InputMode.ROUTER)) } @Test - fun `ESCAPE maps to Escape in InputMode 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`() { + fun `bare x maps to CharInput in ROUTER`() { assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'), InputMode.ROUTER)) } - // --- Regression: semantic chars map to CharInput in non-ROUTER modes --- - @Test - fun `q maps to CharInput in InputMode NAVIGATE`() { - assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.NAVIGATE)) + fun `bare q maps to CharInput in FILTER`() { + assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.FILTER)) } @Test - fun `n maps to CharInput in InputMode NAVIGATE`() { - 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`() { + fun `bare q maps to CharInput in STEER`() { assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.STEER)) } @Test - fun `q maps to CharInput in InputMode FILTER`() { - assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.FILTER)) + fun `bare q maps to CharInput in NAVIGATE`() { + 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 fun `ESC codepoint as CHAR event maps to null`() { - val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B) - assertNull(mapKey(event, InputMode.ROUTER)) + assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B), InputMode.ROUTER)) } @Test fun `NUL codepoint as CHAR event maps to null`() { - val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00) - assertNull(mapKey(event, InputMode.ROUTER)) + assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00), InputMode.ROUTER)) } }