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

- Layout: status bar (borderless), horizontal top row (sessions 30% / active 30% / tools 40%), router fill, input bar fixed
- Components: rewrote all 5 + added ToolsPanel; all accept TuiState directly
- InputBar: 4 modes (ROUTER/NAVIGATE/FILTER/STEER) with context-sensitive keybind hints
- RouterPanel: inline approval/event overlays as last-child text panels
- StatusBar: borderless row with model, providerType, session count, approval badge
- SessionList: manual column/row rendering, no list widget, overflow truncation
- tamboui bumped to 0.3.0
- TambouiKeyMapper: ctrl+key required for all keybinds, bare chars always
  go to input buffer.
- KeyResolver: Quit is ROUTER-only (not global); NavUp/NavDown handled in
  ROUTER as well as NAVIGATE; Filter in ROUTER maps to OpenFilter; CharInput
  in ROUTER returns null (semantic chars handled upstream by mapper).
This commit is contained in:
2026-05-20 00:53:45 +04:00
parent a855eaee45
commit ac5ee9c3e0
16 changed files with 446 additions and 375 deletions
+2
View File
@@ -67,3 +67,5 @@ bin/
/kls_database.db
/code-analysis/
/.infra/sonarqube/docker-compose.yml
/logs
log4j2.properties
+1 -1
View File
@@ -29,7 +29,7 @@ dependencies {
implementation "io.ktor:ktor-client-websockets:$ktor_version"
implementation "io.ktor:ktor-client-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))
}
}