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
|
||||
/code-analysis/
|
||||
/.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-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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String>) {
|
||||
val host = args.getOrElse(0) { "localhost" }
|
||||
@@ -52,7 +39,6 @@ fun main(args: Array<String>) {
|
||||
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<String>) {
|
||||
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
|
||||
|
||||
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]
|
||||
val topRects = Layout.horizontal()
|
||||
.constraints(
|
||||
Constraint.percentage(30),
|
||||
Constraint.percentage(30),
|
||||
Constraint.percentage(40),
|
||||
)
|
||||
.split(vertRects[1])
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -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<Line> {
|
||||
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<Line> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 line = Line.from(
|
||||
Span.styled(promptText, promptStyle),
|
||||
Span.raw("${inputBuffer}_")
|
||||
)
|
||||
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 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()
|
||||
}
|
||||
|
||||
@@ -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<Line> {
|
||||
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()
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
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<Line> {
|
||||
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()
|
||||
)
|
||||
.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> {
|
||||
@@ -43,39 +64,3 @@ fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Span> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 ToggleEventOverlay : 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
|
||||
|
||||
object KeyResolver {
|
||||
fun resolve(key: KeyEvent, mode: InputMode, inputText: String): Action? = when (mode) {
|
||||
InputMode.ROUTER -> resolveRouterMode(key)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ object InputReducer {
|
||||
state: TuiState,
|
||||
action: 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.OpenSteeringPrompt -> if (state.approval.active != null) {
|
||||
state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user