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:
@@ -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
|
||||||
@@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user