chore(damn): detekt, build, tests, formatting

fixed detekt issues where possible.
fixed disttar failing build because tools is added twice in the server module.
added workflowId where required.
fixed some tests not being recognized because of runBlocking without explicit return type.
formatting + imports.
This commit is contained in:
2026-05-22 00:10:05 +04:00
parent 2c459da009
commit f827685ed0
185 changed files with 1134 additions and 832 deletions
@@ -20,12 +20,12 @@ 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private const val DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 1
@@ -70,7 +70,7 @@ fun main(args: Array<String>) {
val handler = EventHandler { event, _ ->
if (event is TambouiKeyEvent) {
mapKey(event, state.inputMode)
mapKey(event)
?.let { KeyResolver.resolve(it, state.inputMode, state.inputBuffer) }
?.let(::dispatch)
}
@@ -23,50 +23,11 @@ fun inputBarWidget(state: TuiState): Paragraph {
?: "(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))
}
InputMode.ROUTER -> createRowsForRouter(state, dimStyle, sessionName, hasApproval, hasSession)
InputMode.NAVIGATE -> createRowsForNavigate(dimStyle, sessionName, hasApproval, hasSession)
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
InputMode.STEER -> createRowsForSteer(state, dimStyle, sessionName)
}
val block = Block.builder()
@@ -77,3 +38,73 @@ fun inputBarWidget(state: TuiState): Paragraph {
return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build()
}
private fun createRowsForSteer(
state: TuiState,
dimStyle: Style?,
sessionName: String,
): Pair<Line?, Line?> {
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))
}
return cursor to Line.from(Span.styled("$sessionName · steering esc cancel enter send", dimStyle))
}
private fun createRowsForFilter(
state: TuiState,
dimStyle: Style?,
): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("/filter…", dimStyle))
}
return cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle))
}
private fun createRowsForNavigate(
dimStyle: Style?,
sessionName: String,
hasApproval: Boolean,
hasSession: Boolean,
): Pair<Line?, Line?> {
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(" ")
return row1Line to Line.from(Span.styled(hints, dimStyle))
}
private fun createRowsForRouter(
state: TuiState,
dimStyle: Style?,
sessionName: String,
hasApproval: Boolean,
hasSession: Boolean,
): Pair<Line?, Line?> {
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(" ")
return cursor to Line.from(Span.styled(hints, dimStyle))
}
@@ -1,7 +1,6 @@
package com.correx.apps.tui.components
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
@@ -28,7 +28,6 @@ fun sessionListWidget(state: TuiState): Paragraph {
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
@@ -61,6 +60,7 @@ private fun statusSpan(s: SessionSummary): Span {
fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
val f = sessions.filter
return if (f.isEmpty()) sessions.sessions
else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) }
val sorted = sessions.sessions.sortedByDescending { it.lastEventAt }
return if (f.isEmpty()) sorted
else sorted.filter { it.workflowId.contains(f, ignoreCase = true) }
}
@@ -1,24 +1,23 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.InputMode
import dev.tamboui.tui.event.KeyCode
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
@Suppress("CyclomaticComplexMethod", "ReturnCount")
fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? {
if (event.isCtrlC()) return KeyEvent.Quit
fun mapKey(event: TambouiKeyEvent): KeyEvent? {
if (event.isCtrlC) return KeyEvent.Quit
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
return when {
event.isChar('q') -> KeyEvent.Quit
event.isChar('n') -> KeyEvent.NewSession
event.isChar('c') -> KeyEvent.Cancel
event.isChar('a') -> KeyEvent.Approve
event.isChar('r') -> KeyEvent.Reject
event.isChar('s') -> KeyEvent.Steer
event.isChar('h') -> KeyEvent.ToggleApprovalOverlay
event.isChar('e') -> KeyEvent.ToggleEventOverlay
else -> null
}
}
@@ -17,11 +17,13 @@ object RootReducer {
val (approval, ae) = ApprovalReducer.reduce(afterInput.approval, prevInputMode, action)
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
val approvalJustArrived = approval.active != null && afterInput.approval.active == null
val withSubReducers = afterInput.copy(
sessions = sessions,
approval = approval,
connection = connection,
provider = provider,
approvalOverlayVisible = if (approvalJustArrived) true else afterInput.approvalOverlayVisible,
)
val final = when (action) {
is Action.ToggleApprovalOverlay -> withSubReducers.copy(
@@ -25,8 +25,18 @@ object SessionsReducer {
action: Action,
clock: () -> Long = System::currentTimeMillis,
): Pair<SessionsState, List<Effect>> = when (action) {
is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) navigateUp(sessions) to emptyList() else sessions to emptyList()
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) navigateDown(sessions) to emptyList() else sessions to emptyList()
is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) {
navigateUp(sessions) to emptyList()
} else {
sessions to emptyList()
}
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) {
navigateDown(sessions) to emptyList()
} else {
sessions to emptyList()
}
is Action.SubmitInput -> if (inputMode == InputMode.FILTER) {
sessions.copy(filter = inputText) to emptyList()
} else {
@@ -52,8 +62,12 @@ object SessionsReducer {
else -> sessions to emptyList()
}
private fun filteredSessions(sessions: SessionsState): List<SessionSummary> =
if (sessions.filter.isBlank()) sessions.sessions
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
private fun navigateUp(sessions: SessionsState): SessionsState {
val list = sessions.sessions
val list = filteredSessions(sessions)
if (list.isEmpty()) return sessions
val idx = list.indexOfFirst { it.id == sessions.selectedId }
val newIdx = if (idx <= 0) list.lastIndex else idx - 1
@@ -61,7 +75,7 @@ object SessionsReducer {
}
private fun navigateDown(sessions: SessionsState): SessionsState {
val list = sessions.sessions
val list = filteredSessions(sessions)
if (list.isEmpty()) return sessions
val idx = list.indexOfFirst { it.id == sessions.selectedId }
val newIdx = if (idx >= list.lastIndex) 0 else idx + 1
@@ -77,194 +91,244 @@ object SessionsReducer {
msg: ServerMessage,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> = when (msg) {
is ServerMessage.SessionStarted -> {
val summary = SessionSummary(
id = msg.sessionId.value,
status = "ACTIVE",
workflowId = msg.workflowId,
name = msg.workflowId,
lastEventAt = clock(),
currentStage = null,
lastOutput = null,
)
val selected = sessions.selectedId ?: msg.sessionId.value
sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to
listOf(Effect.ConnectSession(msg.sessionId))
}
is ServerMessage.SessionPaused -> {
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
"PAUSED awaiting approval"
} else {
"PAUSED"
}
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s
},
) to emptyList()
}
is ServerMessage.SessionCompleted -> touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to listOf(
Effect.DisconnectSession,
)
is ServerMessage.SessionFailed -> touchSession(
sessions,
msg.sessionId.value,
"FAILED",
clock,
) to listOf(Effect.DisconnectSession)
is ServerMessage.StageStarted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageStarted", msg.stageId.value)
s.copy(
currentStage = msg.stageId.value,
currentStageId = msg.stageId.value,
tools = emptyList(),
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.StageCompleted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageCompleted", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.StageFailed -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageFailed", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolStarted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val record = TuiToolRecord(msg.toolName, msg.tier.level, ToolDisplayStatus.STARTED, null)
s.copy(
tools = (s.tools + record).takeLast(8),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
is ServerMessage.ToolCompleted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.COMPLETED)
} else {
t
}
}
val entry = TuiEventEntry(formatTime(now), "ToolCompleted", msg.toolName)
s.copy(
tools = updatedTools,
lastOutput = "${msg.toolName}: ${msg.outputSummary}",
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolFailed -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.FAILED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolRejected -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.REJECTED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions)
is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock)
is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock)
is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock)
is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions)
is ServerMessage.StageCompleted -> processStageCompletedMessage(msg, sessions)
is ServerMessage.StageFailed -> processStageFailedMessage(msg, sessions)
is ServerMessage.ToolStarted -> processToolStartedMessage(clock, sessions, msg)
is ServerMessage.InferenceCompleted -> processInferenceCompletedMessage(sessions, msg)
is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions)
is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions)
is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg)
else -> sessions to emptyList()
}
private fun applyInferenceCompleted(
sessions: SessionsState,
msg: ServerMessage.InferenceCompleted,
private fun processToolRejectedMessage(
clock: () -> Long,
sessions: SessionsState,
msg: ServerMessage.ToolRejected,
): Pair<SessionsState, List<Effect>> {
val now = clock()
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.REJECTED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
private fun processToolFailedMessage(
msg: ServerMessage.ToolFailed,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.FAILED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
private fun processToolCompletedMessage(
msg: ServerMessage.ToolCompleted,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.COMPLETED)
} else {
t
}
}
val entry = TuiEventEntry(formatTime(now), "ToolCompleted", msg.toolName)
s.copy(
tools = updatedTools,
lastOutput = "${msg.toolName}: ${msg.outputSummary}",
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processToolStartedMessage(
clock: () -> Long,
sessions: SessionsState,
msg: ServerMessage.ToolStarted,
): Pair<SessionsState, List<Effect>> {
val now = clock()
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val record = TuiToolRecord(msg.toolName, msg.tier.level, ToolDisplayStatus.STARTED, null)
s.copy(
tools = (s.tools + record).takeLast(8),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processStageFailedMessage(
msg: ServerMessage.StageFailed,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageFailed", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processStageCompletedMessage(
msg: ServerMessage.StageCompleted,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageCompleted", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processStageStartedMessage(
msg: ServerMessage.StageStarted,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageStarted", msg.stageId.value)
s.copy(
currentStage = msg.stageId.value,
currentStageId = msg.stageId.value,
tools = emptyList(),
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processSessionFailedMessage(
sessions: SessionsState,
msg: ServerMessage.SessionFailed,
clock: () -> Long,
): Pair<SessionsState, List<Effect.DisconnectSession>> = touchSession(
sessions,
msg.sessionId.value,
"FAILED",
clock,
) to listOf(Effect.DisconnectSession)
private fun processSessionCompletedMessage(
sessions: SessionsState,
msg: ServerMessage.SessionCompleted,
clock: () -> Long,
): Pair<SessionsState, List<Effect.DisconnectSession>> =
touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to listOf(
Effect.DisconnectSession,
)
private fun processSessionPausedMessage(
msg: ServerMessage.SessionPaused,
sessions: SessionsState,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> {
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
"PAUSED awaiting approval"
} else {
"PAUSED"
}
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s
},
) to emptyList()
}
private fun processSessionStartedMessage(
msg: ServerMessage.SessionStarted,
clock: () -> Long,
sessions: SessionsState,
): Pair<SessionsState, List<Effect.ConnectSession>> {
val summary = SessionSummary(
id = msg.sessionId.value,
status = "ACTIVE",
workflowId = msg.workflowId,
name = msg.workflowId,
lastEventAt = clock(),
currentStage = null,
lastOutput = null,
)
val selected = sessions.selectedId ?: msg.sessionId.value
return sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to
listOf(Effect.ConnectSession(msg.sessionId))
}
private fun processInferenceCompletedMessage(
sessions: SessionsState,
msg: ServerMessage.InferenceCompleted,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
@@ -1,13 +1,12 @@
package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.InputMode
import dev.tamboui.tui.event.KeyCode
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
import dev.tamboui.tui.event.KeyModifiers
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
class TambouiKeyMapperTest {
@@ -15,156 +14,156 @@ class TambouiKeyMapperTest {
@Test
fun `ctrl-c maps to Quit in InputMode ROUTER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.ROUTER))
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
}
@Test
fun `ctrl-c maps to Quit in InputMode FILTER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.FILTER))
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
}
@Test
fun `ctrl-c maps to Quit in InputMode NAVIGATE`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.NAVIGATE))
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
}
@Test
fun `ctrl-c maps to Quit in InputMode STEER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.STEER))
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
}
// --- Ctrl+key → keybind events ---
@Test
fun `ctrl-q maps to Quit`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code), InputMode.ROUTER))
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code)))
}
@Test
fun `ctrl-a maps to Approve`() {
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code), InputMode.ROUTER))
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code)))
}
@Test
fun `ctrl-r maps to Reject`() {
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'r'.code), InputMode.ROUTER))
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'r'.code)))
}
@Test
fun `ctrl-n maps to NewSession`() {
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code), InputMode.ROUTER))
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code)))
}
@Test
fun `ctrl-s maps to Steer`() {
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code), InputMode.ROUTER))
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code)))
}
@Test
fun `ctrl-h maps to ToggleApprovalOverlay`() {
assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code), InputMode.ROUTER))
assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code)))
}
@Test
fun `ctrl-e maps to ToggleEventOverlay`() {
assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code), InputMode.ROUTER))
assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code)))
}
@Test
fun `ctrl+unbound key maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'z'.code), InputMode.ROUTER))
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'z'.code)))
}
// --- Alt always → null ---
@Test
fun `alt modifier maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code), InputMode.ROUTER))
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code)))
}
// --- Bare chars always → CharInput (never trigger keybinds) ---
@Test
fun `bare a maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'), InputMode.ROUTER))
assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a')))
}
@Test
fun `bare q maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.ROUTER))
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
}
@Test
fun `bare s maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'), InputMode.ROUTER))
assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s')))
}
@Test
fun `bare x maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'), InputMode.ROUTER))
assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x')))
}
@Test
fun `bare q maps to CharInput in FILTER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.FILTER))
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
}
@Test
fun `bare q maps to CharInput in STEER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.STEER))
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
}
@Test
fun `bare q maps to CharInput in NAVIGATE`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.NAVIGATE))
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
}
// --- KeyCode mappings ---
@Test
fun `UP maps to NavUp`() {
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.ROUTER))
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP)))
}
@Test
fun `DOWN maps to NavDown`() {
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.ROUTER))
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN)))
}
@Test
fun `ENTER maps to Enter`() {
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.ROUTER))
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER)))
}
@Test
fun `ESCAPE maps to Escape`() {
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.ROUTER))
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE)))
}
@Test
fun `BACKSPACE maps to Backspace`() {
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.ROUTER))
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE)))
}
@Test
fun `TAB maps to Filter`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.ROUTER))
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
}
@Test
fun `TAB maps to Filter in FILTER mode`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.FILTER))
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
}
// --- ISO control chars → null ---
@Test
fun `ESC codepoint as CHAR event maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B), InputMode.ROUTER))
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B)))
}
@Test
fun `NUL codepoint as CHAR event maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00), InputMode.ROUTER))
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00)))
}
}
@@ -51,7 +51,11 @@ class RootReducerTest {
)
val (state2, _) = RootReducer.reduce(state1, Action.ServerEventReceived(startMsg2), fixedClock)
// selected is s1 (first); navigating down should move to s2
val (state3, _) = RootReducer.reduce(state2.copy(inputMode = InputMode.NAVIGATE), Action.NavigateDown, fixedClock)
val (state3, _) = RootReducer.reduce(
state2.copy(inputMode = InputMode.NAVIGATE),
Action.NavigateDown,
fixedClock,
)
assertEquals("s2", state3.sessions.selectedId)
}
@@ -132,7 +132,11 @@ class SessionsReducerTest {
sessions = listOf(session("s1").copy(currentStage = "stage-1")),
selectedId = "s1",
)
val msg = ServerMessage.StageCompleted(sessionId = SessionId("s1"), stageId = StageId("stage-1"))
val msg = ServerMessage.StageCompleted(
sessionId = SessionId("s1"),
stageId = StageId("stage-1"),
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage)
assertEquals(1000L, state.sessions[0].lastEventAt)
@@ -148,6 +152,7 @@ class SessionsReducerTest {
sessionId = SessionId("s1"),
stageId = StageId("stage-1"),
reason = "error",
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage)
@@ -177,6 +182,7 @@ class SessionsReducerTest {
val msg = ServerMessage.InferenceCompleted(
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "summary", responseText = "full response",
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("summary", state.sessions[0].lastOutput)
@@ -190,6 +196,7 @@ class SessionsReducerTest {
val msg = ServerMessage.InferenceCompleted(
sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "", responseText = "",
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].lastResponseText)
@@ -198,7 +205,11 @@ class SessionsReducerTest {
@Test
fun `ServerEventReceived StageStarted sets currentStage`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.StageStarted(sessionId = SessionId("s1"), stageId = StageId("stage-1"))
val msg = ServerMessage.StageStarted(
sessionId = SessionId("s1"),
stageId = StageId("stage-1"),
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("stage-1", state.sessions[0].currentStage)
assertEquals(1000L, state.sessions[0].lastEventAt)
@@ -207,7 +218,12 @@ class SessionsReducerTest {
@Test
fun `ServerEventReceived ToolCompleted updates lastOutput`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.ToolCompleted(sessionId = SessionId("s1"), toolName = "file_write", outputSummary = "wrote 3 lines")
val msg = ServerMessage.ToolCompleted(
sessionId = SessionId("s1"),
toolName = "file_write",
outputSummary = "wrote 3 lines",
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("file_write: wrote 3 lines", state.sessions[0].lastOutput)
assertEquals(1000L, state.sessions[0].lastEventAt)