refactor(tui): event-loop foundation — actions, reducers, effects, Panel
Tasks 1.1–3.3 of docs/plans/2026-05-17-tui-refactor.md:
- Mosaic 0.13 → 0.18; Kotlin 2.0.21 → 2.2.10 (required by Mosaic).
Fix MaxLineLength regressions in core/kernel orchestration logs.
- Input: drop raw System.in reader and ANSI escape parser. Wire
Modifier.onKeyEvent at root; MosaicKeyMapper → KeyResolver → Action.
- State: split TuiState into Connection/Sessions/Input/Approval/Provider
slices. Add InputMode.SteeringNote; delete handleSteer/System.console.
- Reducers: pure RootReducer composes slice reducers returning
(state, List<Effect>). Effect is sealed (SendWs, Quit). Single
dispatch(action) point in TuiApp; one coroutine drains effects.
- TuiWsClient now exposes messages/connection SharedFlows; suspend
callbacks gone. ConnectionEvent sealed type added.
- Rendering: Panel(title){…} composable with LocalTerminalSize
composition local; drop BOX_WIDTH and hand-padding from every
component. Poll stty size every 500ms.
- Tests: KeyResolver (18) + 5 reducer suites (37). 55 total in :apps:tui.
Pending tasks 4.1–5.2 tracked in
docs/plans/2026-05-17-tui-refactor.progress.md.
This commit is contained in:
+20
-1
@@ -17,8 +17,27 @@ dependencies {
|
||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-websockets:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-logging:$ktor_version"
|
||||
|
||||
implementation "com.jakewharton.mosaic:mosaic-runtime:0.13.0"
|
||||
implementation "com.jakewharton.mosaic:mosaic-runtime:0.18.0"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version"
|
||||
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test-junit5"
|
||||
testImplementation "org.junit.jupiter:junit-jupiter:5.10.2"
|
||||
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.2"
|
||||
}
|
||||
|
||||
tasks.named('test') { useJUnitPlatform() }
|
||||
|
||||
tasks.withType(Tar).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||
tasks.withType(Zip).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||
tasks.named("installDist") { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||
|
||||
configurations.configureEach {
|
||||
resolutionStrategy.force(
|
||||
"com.github.ajalt.mordant:mordant:2.6.0",
|
||||
"com.github.ajalt.mordant:mordant-jvm:2.6.0"
|
||||
)
|
||||
}
|
||||
@@ -9,4 +9,9 @@ sealed class KeyEvent {
|
||||
object Steer : KeyEvent()
|
||||
object NavUp : KeyEvent()
|
||||
object NavDown : KeyEvent()
|
||||
object Filter : KeyEvent()
|
||||
object Enter : KeyEvent()
|
||||
object Backspace : KeyEvent()
|
||||
object Escape : KeyEvent()
|
||||
data class CharInput(val ch: Char) : KeyEvent()
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.correx.apps.tui
|
||||
|
||||
import com.correx.apps.server.protocol.PauseReason
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.tui.state.ApprovalInfo
|
||||
import com.correx.apps.tui.state.SessionSummary
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
internal fun applyServerMessage(msg: ServerMessage, state: TuiState): TuiState = when (msg) {
|
||||
is ServerMessage.SessionStarted -> applySessionStarted(msg, state)
|
||||
is ServerMessage.SessionPaused -> applySessionPaused(msg, state)
|
||||
is ServerMessage.SessionCompleted -> touchSession(state, msg.sessionId.value, status = "COMPLETED")
|
||||
is ServerMessage.SessionFailed -> touchSession(state, msg.sessionId.value, status = "FAILED")
|
||||
is ServerMessage.StageStarted -> state.copy(sessions = state.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
s.copy(currentStage = msg.stageId.value, lastEventAt = now())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
})
|
||||
is ServerMessage.StageCompleted -> touchSession(state, msg.sessionId.value)
|
||||
is ServerMessage.StageFailed -> touchSession(state, msg.sessionId.value)
|
||||
is ServerMessage.InferenceCompleted -> state.copy(sessions = state.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) s.copy(lastOutput = msg.outputSummary, lastEventAt = now()) else s
|
||||
})
|
||||
is ServerMessage.ToolCompleted -> state.copy(sessions = state.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
s.copy(lastOutput = "${msg.toolName}: ${msg.outputSummary}", lastEventAt = now())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
})
|
||||
is ServerMessage.ApprovalRequired -> {
|
||||
val info = ApprovalInfo(
|
||||
requestId = msg.requestId.value,
|
||||
sessionId = msg.sessionId.value,
|
||||
tier = msg.tier.name,
|
||||
riskSummary = msg.riskSummary.level,
|
||||
toolName = msg.toolName,
|
||||
preview = msg.preview,
|
||||
)
|
||||
state.copy(activeApproval = info)
|
||||
}
|
||||
is ServerMessage.ProviderStatusChanged -> state.copy(
|
||||
providerId = msg.status.providerId,
|
||||
providerStatus = msg.status.status,
|
||||
)
|
||||
else -> state
|
||||
}
|
||||
|
||||
private fun applySessionStarted(msg: ServerMessage.SessionStarted, state: TuiState): TuiState {
|
||||
val summary = SessionSummary(
|
||||
id = msg.sessionId.value,
|
||||
status = "ACTIVE",
|
||||
workflowId = msg.workflowId,
|
||||
lastEventAt = now(),
|
||||
currentStage = null,
|
||||
lastOutput = null,
|
||||
)
|
||||
val selected = state.selectedSessionId ?: msg.sessionId.value
|
||||
return state.copy(sessions = state.sessions + summary, selectedSessionId = selected)
|
||||
}
|
||||
|
||||
private fun applySessionPaused(msg: ServerMessage.SessionPaused, state: TuiState): TuiState {
|
||||
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
|
||||
"PAUSED awaiting approval"
|
||||
} else {
|
||||
"PAUSED"
|
||||
}
|
||||
return state.copy(sessions = state.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
s.copy(status = statusLabel, lastEventAt = now())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun touchSession(state: TuiState, sessionId: String, status: String? = null): TuiState =
|
||||
state.copy(sessions = state.sessions.map { s ->
|
||||
if (s.id == sessionId) {
|
||||
if (status != null) s.copy(status = status, lastEventAt = now()) else s.copy(lastEventAt = now())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
})
|
||||
|
||||
internal fun now(): Long = System.currentTimeMillis()
|
||||
@@ -1,191 +1,109 @@
|
||||
package com.correx.apps.tui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.correx.apps.server.protocol.ApprovalDecision
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import com.correx.apps.tui.components.ActiveSession
|
||||
import com.correx.apps.tui.components.ApprovalPanel
|
||||
import com.correx.apps.tui.components.InputBar
|
||||
import com.correx.apps.tui.components.LocalTerminalSize
|
||||
import com.correx.apps.tui.components.Panel
|
||||
import com.correx.apps.tui.components.SessionList
|
||||
import com.correx.apps.tui.components.StatusBar
|
||||
import com.correx.apps.tui.components.readTerminalSize
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.input.KeyResolver
|
||||
import com.correx.apps.tui.input.MosaicKeyMapper
|
||||
import com.correx.apps.tui.reducer.EffectDispatcher
|
||||
import com.correx.apps.tui.reducer.Effect
|
||||
import com.correx.apps.tui.reducer.RootReducer
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import com.correx.apps.tui.ws.ConnectionEvent
|
||||
import com.correx.apps.tui.ws.TuiWsClient
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.jakewharton.mosaic.layout.onKeyEvent
|
||||
import com.jakewharton.mosaic.modifier.Modifier
|
||||
import com.jakewharton.mosaic.runMosaic
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val TERMINAL_SIZE_POLL_MS = 500L
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
private fun Keybinds() {
|
||||
Text("n new r resume c cancel a approve q quit ↑↓ navigate")
|
||||
}
|
||||
|
||||
suspend fun runTuiApp(host: String, port: Int) {
|
||||
var state by mutableStateOf(TuiState())
|
||||
val keyChannel = Channel<KeyEvent>(capacity = Channel.BUFFERED)
|
||||
var quit by mutableStateOf(false)
|
||||
var terminalSize by mutableStateOf(readTerminalSize())
|
||||
|
||||
val wsClient = TuiWsClient(
|
||||
host = host,
|
||||
port = port,
|
||||
onConnected = { state = state.copy(connected = true, reconnecting = false) },
|
||||
onDisconnected = { state = state.copy(connected = false, reconnecting = true) },
|
||||
onMessage = { msg -> state = applyServerMessage(msg, state) },
|
||||
)
|
||||
val effects = Channel<Effect>(Channel.BUFFERED)
|
||||
val ws = TuiWsClient(host = host, port = port)
|
||||
val dispatcher = EffectDispatcher(ws) { quit = true }
|
||||
|
||||
runMosaic {
|
||||
setContent {
|
||||
Column {
|
||||
Text("─── status ───────────────────────────────────────────────")
|
||||
StatusBar(state)
|
||||
Text("─── sessions ─────────────────────────────────────────────")
|
||||
SessionList(state.sessions, state.selectedSessionId)
|
||||
Text("─── active session ───────────────────────────────────────")
|
||||
ActiveSession(state.sessions.find { it.id == state.selectedSessionId })
|
||||
if (state.activeApproval != null) {
|
||||
Text("─── approval ─────────────────────────────────────────────")
|
||||
ApprovalPanel(state.activeApproval)
|
||||
fun dispatch(action: Action) {
|
||||
val (next, emitted) = RootReducer.reduce(state, action)
|
||||
Snapshot.withMutableSnapshot { state = next }
|
||||
emitted.forEach { effects.trySend(it) }
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
launch { ws.connect() }
|
||||
launch { ws.messages.collect { dispatch(Action.ServerEventReceived(it)) } }
|
||||
launch {
|
||||
ws.connection.collect { event ->
|
||||
dispatch(
|
||||
when (event) {
|
||||
is ConnectionEvent.Connected -> Action.Connected
|
||||
is ConnectionEvent.Disconnected -> Action.Disconnected
|
||||
is ConnectionEvent.RetryScheduled -> Action.RetryScheduled(event.attempt, event.nextRetryAtMs)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
launch { for (e in effects) dispatcher.dispatch(e) }
|
||||
launch {
|
||||
while (isActive) {
|
||||
delay(TERMINAL_SIZE_POLL_MS)
|
||||
val current = readTerminalSize()
|
||||
if (current != terminalSize) Snapshot.withMutableSnapshot { terminalSize = current }
|
||||
}
|
||||
}
|
||||
|
||||
runMosaic {
|
||||
CompositionLocalProvider(LocalTerminalSize provides terminalSize) {
|
||||
Column(
|
||||
modifier = Modifier.onKeyEvent { mosaicKey ->
|
||||
val key = MosaicKeyMapper.toDomain(mosaicKey) ?: return@onKeyEvent false
|
||||
val action = KeyResolver.resolve(key, state.input.mode, state.input.text)
|
||||
?: return@onKeyEvent true
|
||||
dispatch(action)
|
||||
true
|
||||
},
|
||||
) {
|
||||
Panel(title = "status") { StatusBar(state) }
|
||||
Panel(title = "session list") { SessionList(state.sessions.sessions, state.sessions.selectedId) }
|
||||
val activeSession = state.sessions.sessions.find { it.id == state.sessions.selectedId }
|
||||
Panel(title = "active session") { ActiveSession(activeSession) }
|
||||
if (state.approval.active != null) {
|
||||
Panel(title = "approval") { ApprovalPanel(state.approval.active!!) }
|
||||
}
|
||||
Panel(title = "input") { InputBar(state.input) }
|
||||
Panel(title = "keybinds") { Keybinds() }
|
||||
}
|
||||
Text("─────────────────────────────────────────────────────────")
|
||||
InputBar(state.inputText)
|
||||
Text("q:quit n:new c:cancel ↑↓:navigate a/r/s:approval")
|
||||
}
|
||||
}
|
||||
|
||||
launch { wsClient.connect() }
|
||||
launch { readKeys(keyChannel) }
|
||||
launch { handleKeys(keyChannel, wsClient, getState = { state }, setState = { state = it }) }
|
||||
}
|
||||
|
||||
wsClient.close()
|
||||
}
|
||||
|
||||
private suspend fun readKeys(keyChannel: Channel<KeyEvent>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val stdin = System.`in`
|
||||
var running = true
|
||||
while (running) {
|
||||
val b = stdin.read()
|
||||
if (b == -1) break
|
||||
val event = charToKeyEvent(b.toChar(), stdin)
|
||||
if (event != null) {
|
||||
keyChannel.send(event)
|
||||
if (event is KeyEvent.Quit) running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun charToKeyEvent(ch: Char, stdin: java.io.InputStream): KeyEvent? = when (ch) {
|
||||
'q' -> KeyEvent.Quit
|
||||
'n' -> KeyEvent.NewSession
|
||||
'c' -> KeyEvent.Cancel
|
||||
'a' -> KeyEvent.Approve
|
||||
'r' -> KeyEvent.Reject
|
||||
's' -> KeyEvent.Steer
|
||||
'k', '' -> {
|
||||
val next = stdin.read()
|
||||
if (next == '['.code) {
|
||||
when (stdin.read().toChar()) {
|
||||
'A' -> KeyEvent.NavUp
|
||||
'B' -> KeyEvent.NavDown
|
||||
else -> null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private suspend fun handleKeys(
|
||||
keyChannel: Channel<KeyEvent>,
|
||||
wsClient: TuiWsClient,
|
||||
getState: () -> TuiState,
|
||||
setState: (TuiState) -> Unit,
|
||||
) {
|
||||
for (event in keyChannel) {
|
||||
val state = getState()
|
||||
when (event) {
|
||||
is KeyEvent.Quit -> { wsClient.close(); return }
|
||||
is KeyEvent.NewSession -> handleNewSession(wsClient)
|
||||
is KeyEvent.Cancel -> handleCancel(state, wsClient)
|
||||
is KeyEvent.Approve -> handleApproval(state, wsClient, ApprovalDecision.APPROVE, null, setState)
|
||||
is KeyEvent.Reject -> handleApproval(state, wsClient, ApprovalDecision.REJECT, null, setState)
|
||||
is KeyEvent.Steer -> handleSteer(state, wsClient, setState)
|
||||
is KeyEvent.NavUp -> setState(navigateUp(state))
|
||||
is KeyEvent.NavDown -> setState(navigateDown(state))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleNewSession(wsClient: TuiWsClient) {
|
||||
val workflow = withContext(Dispatchers.IO) {
|
||||
System.console()?.readLine("workflow id: ")
|
||||
} ?: return
|
||||
wsClient.send(ClientMessage.StartSession(workflowId = workflow, config = null))
|
||||
}
|
||||
|
||||
private suspend fun handleCancel(state: TuiState, wsClient: TuiWsClient) {
|
||||
state.selectedSessionId?.let { id ->
|
||||
wsClient.send(ClientMessage.CancelSession(sessionId = SessionId(id)))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleApproval(
|
||||
state: TuiState,
|
||||
wsClient: TuiWsClient,
|
||||
decision: ApprovalDecision,
|
||||
note: String?,
|
||||
setState: (TuiState) -> Unit,
|
||||
) {
|
||||
state.activeApproval?.let { info ->
|
||||
wsClient.send(
|
||||
ClientMessage.ApprovalResponse(
|
||||
requestId = ApprovalRequestId(info.requestId),
|
||||
decision = decision,
|
||||
steeringNote = note,
|
||||
)
|
||||
)
|
||||
setState(state.copy(activeApproval = null))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleSteer(
|
||||
state: TuiState,
|
||||
wsClient: TuiWsClient,
|
||||
setState: (TuiState) -> Unit,
|
||||
) {
|
||||
state.activeApproval?.let { info ->
|
||||
val note = withContext(Dispatchers.IO) {
|
||||
System.console()?.readLine("steering note: ")
|
||||
}
|
||||
wsClient.send(
|
||||
ClientMessage.ApprovalResponse(
|
||||
requestId = ApprovalRequestId(info.requestId),
|
||||
decision = ApprovalDecision.STEER,
|
||||
steeringNote = note,
|
||||
)
|
||||
)
|
||||
setState(state.copy(activeApproval = null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateUp(state: TuiState): TuiState {
|
||||
val sessions = state.sessions
|
||||
if (sessions.isEmpty()) return state
|
||||
val idx = sessions.indexOfFirst { it.id == state.selectedSessionId }
|
||||
val newIdx = if (idx <= 0) sessions.lastIndex else idx - 1
|
||||
return state.copy(selectedSessionId = sessions[newIdx].id)
|
||||
}
|
||||
|
||||
private fun navigateDown(state: TuiState): TuiState {
|
||||
val sessions = state.sessions
|
||||
if (sessions.isEmpty()) return state
|
||||
val idx = sessions.indexOfFirst { it.id == state.selectedSessionId }
|
||||
val newIdx = if (idx >= sessions.lastIndex) 0 else idx + 1
|
||||
return state.copy(selectedSessionId = sessions[newIdx].id)
|
||||
ws.close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.correx.apps.tui
|
||||
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
|
||||
internal fun navigateUp(state: TuiState): TuiState {
|
||||
val sessions = state.sessions.sessions
|
||||
if (sessions.isEmpty()) return state
|
||||
val idx = sessions.indexOfFirst { it.id == state.sessions.selectedId }
|
||||
val newIdx = if (idx <= 0) sessions.lastIndex else idx - 1
|
||||
return state.copy(sessions = state.sessions.copy(selectedId = sessions[newIdx].id))
|
||||
}
|
||||
|
||||
internal fun navigateDown(state: TuiState): TuiState {
|
||||
val sessions = state.sessions.sessions
|
||||
if (sessions.isEmpty()) return state
|
||||
val idx = sessions.indexOfFirst { it.id == state.sessions.selectedId }
|
||||
val newIdx = if (idx >= sessions.lastIndex) 0 else idx + 1
|
||||
return state.copy(sessions = state.sessions.copy(selectedId = sessions[newIdx].id))
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import com.jakewharton.mosaic.ui.Text
|
||||
fun ActiveSession(session: SessionSummary?) {
|
||||
Column {
|
||||
if (session == null) {
|
||||
Text(" (no active session selected)")
|
||||
Text("(no active session selected)")
|
||||
} else {
|
||||
Text("stage: ${session.currentStage ?: "—"}")
|
||||
Text("last output: ${session.lastOutput ?: "—"}")
|
||||
|
||||
@@ -7,12 +7,11 @@ import com.jakewharton.mosaic.ui.Text
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun ApprovalPanel(approval: ApprovalInfo?) {
|
||||
if (approval == null) return
|
||||
fun ApprovalPanel(approval: ApprovalInfo) {
|
||||
Column {
|
||||
Text("⚠ APPROVAL REQUIRED — Tier ${approval.tier}")
|
||||
Text("risk: ${approval.riskSummary}")
|
||||
approval.toolName?.let { Text("tool: $it") }
|
||||
Text("risk: ${approval.riskSummary}")
|
||||
approval.preview?.let { Text("preview: $it") }
|
||||
Text("[A] approve [R] reject [S] steer")
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.apps.tui.state.InputState
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun InputBar(text: String) {
|
||||
Text("> ${text}_")
|
||||
fun InputBar(input: InputState) {
|
||||
val label = when (input.mode) {
|
||||
InputMode.None -> ">"
|
||||
InputMode.WorkflowId -> "workflow id:"
|
||||
InputMode.SteeringNote -> "steering note:"
|
||||
InputMode.Filter -> "filter:"
|
||||
}
|
||||
Text("$label ${input.text}▌")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun Panel(title: String?, content: @Composable () -> Unit) {
|
||||
val width = LocalTerminalSize.current.width
|
||||
Column {
|
||||
Text(headerLine(title, width))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private fun headerLine(title: String?, width: Int): String {
|
||||
if (title == null) return "├" + "─".repeat((width - 2).coerceAtLeast(0)) + "┤"
|
||||
val label = "─ $title "
|
||||
val fill = (width - 2 - label.length).coerceAtLeast(0)
|
||||
return "├$label" + "─".repeat(fill) + "┤"
|
||||
}
|
||||
@@ -15,14 +15,14 @@ private const val SECS_PER_HOUR = 3600L
|
||||
fun SessionList(sessions: List<SessionSummary>, selectedId: String?) {
|
||||
Column {
|
||||
if (sessions.isEmpty()) {
|
||||
Text(" (no sessions)")
|
||||
Text("(no sessions)")
|
||||
} else {
|
||||
sessions.forEach { session ->
|
||||
val prefix = if (session.id == selectedId) "▶" else " "
|
||||
val pointer = if (session.id == selectedId) "▶" else " "
|
||||
val stage = session.currentStage?.let { " stage $it" } ?: ""
|
||||
val ago = formatAgo(session.lastEventAt)
|
||||
val shortId = session.id.take(SESSION_ID_DISPLAY_LENGTH)
|
||||
Text("$prefix [$shortId] \"${session.workflowId}\" ${session.status}$stage $ago")
|
||||
Text("$pointer [$shortId] \"${session.workflowId}\" ${session.status}$stage $ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ import com.jakewharton.mosaic.ui.Text
|
||||
@Composable
|
||||
fun StatusBar(state: TuiState) {
|
||||
val connectionLabel = when {
|
||||
state.reconnecting -> "reconnecting..."
|
||||
state.connected -> "● connected"
|
||||
state.connection.reconnecting -> "○ reconnecting..."
|
||||
state.connection.connected -> "● connected"
|
||||
else -> "○ disconnected"
|
||||
}
|
||||
val providerLabel = if (state.providerId.isNotEmpty()) {
|
||||
"${state.providerId} (${state.providerStatus})"
|
||||
val providerLabel = if (state.provider.id.isNotEmpty()) {
|
||||
"${state.provider.id} (${state.provider.status})"
|
||||
} else {
|
||||
state.providerStatus
|
||||
state.provider.status
|
||||
}
|
||||
Text("server: $connectionLabel │ provider: $providerLabel")
|
||||
Text("server: $connectionLabel │ provider: $providerLabel")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
data class TerminalSize(val width: Int, val height: Int)
|
||||
|
||||
val LocalTerminalSize = staticCompositionLocalOf { TerminalSize(width = 80, height = 24) }
|
||||
|
||||
internal fun readTerminalSize(): TerminalSize = runCatching {
|
||||
val process = ProcessBuilder("stty", "size")
|
||||
.redirectErrorStream(true)
|
||||
.redirectInput(java.io.File("/dev/tty"))
|
||||
.start()
|
||||
val line = process.inputStream.bufferedReader().readLine().orEmpty()
|
||||
process.waitFor()
|
||||
val parts = line.trim().split(" ")
|
||||
TerminalSize(width = parts[1].toInt(), height = parts[0].toInt())
|
||||
}.getOrDefault(TerminalSize(width = 80, height = 24))
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
|
||||
sealed interface Action {
|
||||
data object Quit : Action
|
||||
data object OpenNewSessionPrompt : Action
|
||||
data object CancelSelectedSession : Action
|
||||
data object ApproveActive : Action
|
||||
data object RejectActive : Action
|
||||
data object OpenSteeringPrompt : Action
|
||||
data object NavigateUp : Action
|
||||
data object NavigateDown : Action
|
||||
data object OpenFilter : Action
|
||||
data class AppendChar(val ch: Char) : Action
|
||||
data object Backspace : Action
|
||||
data object SubmitInput : Action
|
||||
data object CancelInput : Action
|
||||
data class ServerEventReceived(val message: ServerMessage) : Action
|
||||
data object Connected : Action
|
||||
data object Disconnected : Action
|
||||
data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : Action
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
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.None -> resolveCommandMode(key)
|
||||
else -> resolveInputMode(key, inputText)
|
||||
}
|
||||
|
||||
private fun resolveCommandMode(key: KeyEvent): Action? = when (key) {
|
||||
KeyEvent.Quit -> Action.Quit
|
||||
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
|
||||
KeyEvent.Cancel -> Action.CancelSelectedSession
|
||||
KeyEvent.Approve -> Action.ApproveActive
|
||||
KeyEvent.Reject -> Action.RejectActive
|
||||
KeyEvent.Steer -> Action.OpenSteeringPrompt
|
||||
KeyEvent.NavUp -> Action.NavigateUp
|
||||
KeyEvent.NavDown -> Action.NavigateDown
|
||||
KeyEvent.Filter -> Action.OpenFilter
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun resolveInputMode(key: KeyEvent, inputText: String): Action? = when (key) {
|
||||
KeyEvent.Escape -> Action.CancelInput
|
||||
KeyEvent.Backspace -> Action.Backspace
|
||||
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
|
||||
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.tui.KeyEvent
|
||||
import com.jakewharton.mosaic.layout.KeyEvent as MosaicKeyEvent
|
||||
|
||||
object MosaicKeyMapper {
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
fun toDomain(event: MosaicKeyEvent): KeyEvent? {
|
||||
if (event.ctrl || event.alt) return null
|
||||
return when (event.key) {
|
||||
"q" -> KeyEvent.Quit
|
||||
"n" -> KeyEvent.NewSession
|
||||
"c" -> KeyEvent.Cancel
|
||||
"a" -> KeyEvent.Approve
|
||||
"r" -> KeyEvent.Reject
|
||||
"s" -> KeyEvent.Steer
|
||||
"/" -> KeyEvent.Filter
|
||||
"ArrowUp" -> KeyEvent.NavUp
|
||||
"ArrowDown" -> KeyEvent.NavDown
|
||||
"Enter" -> KeyEvent.Enter
|
||||
"Backspace" -> KeyEvent.Backspace
|
||||
"Escape" -> KeyEvent.Escape
|
||||
else -> {
|
||||
val ch = event.key.singleOrNull()
|
||||
if (ch != null && !ch.isISOControl()) KeyEvent.CharInput(ch) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ApprovalDecision
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.ApprovalInfo
|
||||
import com.correx.apps.tui.state.ApprovalState
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
|
||||
object ApprovalReducer {
|
||||
fun reduce(
|
||||
approval: ApprovalState,
|
||||
inputMode: InputMode,
|
||||
action: Action,
|
||||
): Pair<ApprovalState, List<Effect>> = when (action) {
|
||||
is Action.ApproveActive -> {
|
||||
val active = approval.active
|
||||
if (active != null) {
|
||||
ApprovalState(active = null) to listOf(
|
||||
Effect.SendWs(
|
||||
ClientMessage.ApprovalResponse(
|
||||
requestId = ApprovalRequestId(active.requestId),
|
||||
decision = ApprovalDecision.APPROVE,
|
||||
steeringNote = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
approval to emptyList()
|
||||
}
|
||||
}
|
||||
is Action.RejectActive -> {
|
||||
val active = approval.active
|
||||
if (active != null) {
|
||||
ApprovalState(active = null) to listOf(
|
||||
Effect.SendWs(
|
||||
ClientMessage.ApprovalResponse(
|
||||
requestId = ApprovalRequestId(active.requestId),
|
||||
decision = ApprovalDecision.REJECT,
|
||||
steeringNote = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
approval to emptyList()
|
||||
}
|
||||
}
|
||||
is Action.SubmitInput -> if (inputMode == InputMode.SteeringNote) {
|
||||
ApprovalState(active = null) to emptyList()
|
||||
} else {
|
||||
approval to emptyList()
|
||||
}
|
||||
is Action.ServerEventReceived -> when (val msg = action.message) {
|
||||
is ServerMessage.ApprovalRequired -> {
|
||||
val info = ApprovalInfo(
|
||||
requestId = msg.requestId.value,
|
||||
sessionId = msg.sessionId.value,
|
||||
tier = msg.tier.name,
|
||||
riskSummary = msg.riskSummary.level,
|
||||
toolName = msg.toolName,
|
||||
preview = msg.preview,
|
||||
)
|
||||
ApprovalState(active = info) to emptyList()
|
||||
}
|
||||
else -> approval to emptyList()
|
||||
}
|
||||
else -> approval to emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.ConnectionState
|
||||
|
||||
object ConnectionReducer {
|
||||
fun reduce(
|
||||
connection: ConnectionState,
|
||||
action: Action,
|
||||
): Pair<ConnectionState, List<Effect>> = when (action) {
|
||||
is Action.Connected ->
|
||||
ConnectionState(connected = true, reconnecting = false, attempt = 0, nextRetryAtMs = null) to emptyList()
|
||||
is Action.Disconnected -> connection.copy(connected = false, reconnecting = true) to emptyList()
|
||||
is Action.RetryScheduled -> connection.copy(
|
||||
reconnecting = true,
|
||||
attempt = action.attempt,
|
||||
nextRetryAtMs = action.nextRetryAtMs,
|
||||
) to emptyList()
|
||||
else -> connection to emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
|
||||
sealed interface Effect {
|
||||
data class SendWs(val message: ClientMessage) : Effect
|
||||
data object Quit : Effect
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.tui.ws.TuiWsClient
|
||||
|
||||
class EffectDispatcher(
|
||||
private val wsClient: TuiWsClient,
|
||||
private val onQuit: () -> Unit,
|
||||
) {
|
||||
suspend fun dispatch(effect: Effect) {
|
||||
when (effect) {
|
||||
is Effect.SendWs -> wsClient.send(effect.message)
|
||||
Effect.Quit -> onQuit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ApprovalDecision
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.ApprovalInfo
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.apps.tui.state.InputState
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
object InputReducer {
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
fun reduce(
|
||||
input: InputState,
|
||||
activeApproval: ApprovalInfo?,
|
||||
action: Action,
|
||||
): Pair<InputState, List<Effect>> = when (action) {
|
||||
is Action.OpenNewSessionPrompt -> InputState(InputMode.WorkflowId, "") to emptyList()
|
||||
is Action.OpenSteeringPrompt -> if (activeApproval != null) {
|
||||
InputState(InputMode.SteeringNote, "") to emptyList()
|
||||
} else {
|
||||
input to emptyList()
|
||||
}
|
||||
is Action.OpenFilter -> InputState(InputMode.Filter, "") to emptyList()
|
||||
is Action.AppendChar -> input.copy(text = input.text + action.ch) to emptyList()
|
||||
is Action.Backspace -> input.copy(text = input.text.dropLast(1)) to emptyList()
|
||||
is Action.CancelInput -> InputState(InputMode.None, "") to emptyList()
|
||||
is Action.SubmitInput -> when (input.mode) {
|
||||
InputMode.WorkflowId -> {
|
||||
val text = input.text.trim()
|
||||
if (text.isNotBlank()) {
|
||||
InputState(InputMode.None, "") to listOf(
|
||||
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)),
|
||||
)
|
||||
} else {
|
||||
InputState(InputMode.None, "") to emptyList()
|
||||
}
|
||||
}
|
||||
InputMode.SteeringNote -> {
|
||||
val text = input.text.trim()
|
||||
if (activeApproval != null && text.isNotBlank()) {
|
||||
InputState(InputMode.None, "") to listOf(
|
||||
Effect.SendWs(
|
||||
ClientMessage.ApprovalResponse(
|
||||
requestId = ApprovalRequestId(activeApproval.requestId),
|
||||
decision = ApprovalDecision.STEER,
|
||||
steeringNote = text,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
InputState(InputMode.None, "") to emptyList()
|
||||
}
|
||||
}
|
||||
InputMode.Filter -> InputState(InputMode.None, "") to emptyList()
|
||||
InputMode.None -> input to emptyList()
|
||||
}
|
||||
else -> input to emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.ProviderState
|
||||
|
||||
object ProviderReducer {
|
||||
fun reduce(
|
||||
provider: ProviderState,
|
||||
action: Action,
|
||||
): Pair<ProviderState, List<Effect>> = when (action) {
|
||||
is Action.ServerEventReceived -> when (val msg = action.message) {
|
||||
is ServerMessage.ProviderStatusChanged -> ProviderState(
|
||||
id = msg.status.providerId,
|
||||
status = msg.status.status,
|
||||
) to emptyList()
|
||||
else -> provider to emptyList()
|
||||
}
|
||||
else -> provider to emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
|
||||
object RootReducer {
|
||||
fun reduce(
|
||||
state: TuiState,
|
||||
action: Action,
|
||||
clock: () -> Long = System::currentTimeMillis,
|
||||
): Pair<TuiState, List<Effect>> {
|
||||
val quitEffects: List<Effect> = if (action is Action.Quit) listOf(Effect.Quit) else emptyList()
|
||||
val (input, ie) = InputReducer.reduce(state.input, state.approval.active, action)
|
||||
val (sessions, se) = SessionsReducer.reduce(state.sessions, state.input.mode, state.input.text, action, clock)
|
||||
val (approval, ae) = ApprovalReducer.reduce(state.approval, state.input.mode, action)
|
||||
val (connection, ce) = ConnectionReducer.reduce(state.connection, action)
|
||||
val (provider, pe) = ProviderReducer.reduce(state.provider, action)
|
||||
return state.copy(
|
||||
input = input,
|
||||
sessions = sessions,
|
||||
approval = approval,
|
||||
connection = connection,
|
||||
provider = provider,
|
||||
) to (quitEffects + ie + se + ae + ce + pe)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
|
||||
/**
|
||||
* Thin adapter that wraps a ServerMessage in ServerEventReceived and fans it through the root reducer.
|
||||
* Retained so existing callers that were using StateReducer.kt logic have a home during the transition.
|
||||
*/
|
||||
internal fun serverMessageToAction(msg: ServerMessage): Action = Action.ServerEventReceived(msg)
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.PauseReason
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.apps.tui.state.SessionSummary
|
||||
import com.correx.apps.tui.state.SessionsState
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
object SessionsReducer {
|
||||
fun reduce(
|
||||
sessions: SessionsState,
|
||||
inputMode: InputMode,
|
||||
inputText: String,
|
||||
action: Action,
|
||||
clock: () -> Long = System::currentTimeMillis,
|
||||
): Pair<SessionsState, List<Effect>> = when (action) {
|
||||
is Action.NavigateUp -> navigateUp(sessions) to emptyList()
|
||||
is Action.NavigateDown -> navigateDown(sessions) to emptyList()
|
||||
is Action.SubmitInput -> if (inputMode == InputMode.Filter) {
|
||||
sessions.copy(filter = inputText) to emptyList()
|
||||
} else {
|
||||
sessions to emptyList()
|
||||
}
|
||||
is Action.CancelInput -> if (inputMode == InputMode.Filter) {
|
||||
sessions.copy(filter = "") to emptyList()
|
||||
} else {
|
||||
sessions to emptyList()
|
||||
}
|
||||
is Action.CancelSelectedSession -> {
|
||||
val id = sessions.selectedId
|
||||
if (id != null) {
|
||||
sessions to listOf(Effect.SendWs(ClientMessage.CancelSession(sessionId = SessionId(id))))
|
||||
} else {
|
||||
sessions to emptyList()
|
||||
}
|
||||
}
|
||||
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock) to emptyList()
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
|
||||
private fun navigateUp(sessions: SessionsState): SessionsState {
|
||||
val list = sessions.sessions
|
||||
if (list.isEmpty()) return sessions
|
||||
val idx = list.indexOfFirst { it.id == sessions.selectedId }
|
||||
val newIdx = if (idx <= 0) list.lastIndex else idx - 1
|
||||
return sessions.copy(selectedId = list[newIdx].id)
|
||||
}
|
||||
|
||||
private fun navigateDown(sessions: SessionsState): SessionsState {
|
||||
val list = sessions.sessions
|
||||
if (list.isEmpty()) return sessions
|
||||
val idx = list.indexOfFirst { it.id == sessions.selectedId }
|
||||
val newIdx = if (idx >= list.lastIndex) 0 else idx + 1
|
||||
return sessions.copy(selectedId = list[newIdx].id)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun applyServerMessage(
|
||||
sessions: SessionsState,
|
||||
msg: ServerMessage,
|
||||
clock: () -> Long,
|
||||
): SessionsState = when (msg) {
|
||||
is ServerMessage.SessionStarted -> {
|
||||
val summary = SessionSummary(
|
||||
id = msg.sessionId.value,
|
||||
status = "ACTIVE",
|
||||
workflowId = msg.workflowId,
|
||||
lastEventAt = clock(),
|
||||
currentStage = null,
|
||||
lastOutput = null,
|
||||
)
|
||||
val selected = sessions.selectedId ?: msg.sessionId.value
|
||||
sessions.copy(sessions = sessions.sessions + summary, selectedId = selected)
|
||||
}
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
is ServerMessage.SessionCompleted -> touchSession(sessions, msg.sessionId.value, "COMPLETED", clock)
|
||||
is ServerMessage.SessionFailed -> touchSession(sessions, msg.sessionId.value, "FAILED", clock)
|
||||
is ServerMessage.StageStarted -> sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
s.copy(currentStage = msg.stageId.value, lastEventAt = clock())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
)
|
||||
is ServerMessage.StageCompleted -> touchSession(sessions, msg.sessionId.value, null, clock)
|
||||
is ServerMessage.StageFailed -> touchSession(sessions, msg.sessionId.value, null, clock)
|
||||
is ServerMessage.InferenceCompleted -> sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
s.copy(lastOutput = msg.outputSummary, lastEventAt = clock())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
)
|
||||
is ServerMessage.ToolCompleted -> sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
s.copy(lastOutput = "${msg.toolName}: ${msg.outputSummary}", lastEventAt = clock())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
)
|
||||
else -> sessions
|
||||
}
|
||||
|
||||
private fun touchSession(
|
||||
sessions: SessionsState,
|
||||
sessionId: String,
|
||||
status: String?,
|
||||
clock: () -> Long,
|
||||
): SessionsState = sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == sessionId) {
|
||||
if (status != null) s.copy(status = status, lastEventAt = clock()) else s.copy(lastEventAt = clock())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
data class ApprovalState(
|
||||
val active: ApprovalInfo? = null,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
data class ConnectionState(
|
||||
val connected: Boolean = false,
|
||||
val reconnecting: Boolean = false,
|
||||
val attempt: Int = 0,
|
||||
val nextRetryAtMs: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
data class InputState(
|
||||
val mode: InputMode = InputMode.None,
|
||||
val text: String = "",
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
data class ProviderState(
|
||||
val id: String = "",
|
||||
val status: String = "unknown",
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
data class SessionsState(
|
||||
val sessions: List<SessionSummary> = emptyList(),
|
||||
val selectedId: String? = null,
|
||||
val filter: String = "",
|
||||
)
|
||||
@@ -1,14 +1,13 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
enum class InputMode { None, WorkflowId, SteeringNote, Filter }
|
||||
|
||||
data class TuiState(
|
||||
val connected: Boolean = false,
|
||||
val reconnecting: Boolean = false,
|
||||
val sessions: List<SessionSummary> = emptyList(),
|
||||
val selectedSessionId: String? = null,
|
||||
val activeApproval: ApprovalInfo? = null,
|
||||
val inputText: String = "",
|
||||
val providerStatus: String = "unknown",
|
||||
val providerId: String = "",
|
||||
val connection: ConnectionState = ConnectionState(),
|
||||
val sessions: SessionsState = SessionsState(),
|
||||
val input: InputState = InputState(),
|
||||
val approval: ApprovalState = ApprovalState(),
|
||||
val provider: ProviderState = ProviderState(),
|
||||
)
|
||||
|
||||
data class SessionSummary(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.apps.tui.ws
|
||||
|
||||
sealed interface ConnectionEvent {
|
||||
data object Connected : ConnectionEvent
|
||||
data object Disconnected : ConnectionEvent
|
||||
data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : ConnectionEvent
|
||||
}
|
||||
@@ -4,13 +4,19 @@ import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.WebSocketSession
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@@ -20,9 +26,6 @@ private const val MAX_RETRY_DELAY_MS = 30_000L
|
||||
class TuiWsClient(
|
||||
private val host: String,
|
||||
private val port: Int,
|
||||
private val onConnected: suspend () -> Unit,
|
||||
private val onDisconnected: suspend () -> Unit,
|
||||
private val onMessage: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
private val json = Json {
|
||||
classDiscriminator = "type"
|
||||
@@ -31,10 +34,19 @@ class TuiWsClient(
|
||||
|
||||
private val client = HttpClient(CIO) {
|
||||
install(WebSockets)
|
||||
install(Logging) {
|
||||
level = LogLevel.NONE
|
||||
}
|
||||
}
|
||||
|
||||
private var session: WebSocketSession? = null
|
||||
|
||||
private val _messages = MutableSharedFlow<ServerMessage>(extraBufferCapacity = 64)
|
||||
val messages: SharedFlow<ServerMessage> = _messages.asSharedFlow()
|
||||
|
||||
private val _connection = MutableSharedFlow<ConnectionEvent>(extraBufferCapacity = 16)
|
||||
val connection: SharedFlow<ConnectionEvent> = _connection.asSharedFlow()
|
||||
|
||||
suspend fun send(message: ClientMessage) {
|
||||
runCatching {
|
||||
session?.send(json.encodeToString(message))
|
||||
@@ -45,23 +57,24 @@ class TuiWsClient(
|
||||
var delayMs = INITIAL_RETRY_DELAY_MS
|
||||
while (true) {
|
||||
runCatching {
|
||||
client.webSocket(host = host, port = port, path = "/stream") {
|
||||
client.webSocket(method = HttpMethod.Get, host = host, port = port, path = "/stream") {
|
||||
session = this
|
||||
onConnected()
|
||||
_connection.tryEmit(ConnectionEvent.Connected)
|
||||
delayMs = INITIAL_RETRY_DELAY_MS
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
runCatching {
|
||||
json.decodeFromString<ServerMessage>(frame.readText())
|
||||
}.onSuccess { msg ->
|
||||
onMessage(msg)
|
||||
_messages.tryEmit(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { println("WS connect failed: ${it.message}") }
|
||||
session = null
|
||||
onDisconnected()
|
||||
_connection.tryEmit(ConnectionEvent.Disconnected)
|
||||
// TODO Task 4.3: emit RetryScheduled with attempt count and nextRetryAtMs
|
||||
delay(delayMs)
|
||||
delayMs = minOf(delayMs * 2, MAX_RETRY_DELAY_MS)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.tui.KeyEvent
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class KeyResolverTest {
|
||||
@Test
|
||||
fun `n in None mode resolves to OpenNewSessionPrompt`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.NewSession, InputMode.None, "")
|
||||
assertEquals(Action.OpenNewSessionPrompt, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `command letters in input mode resolve to null`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.NewSession, InputMode.WorkflowId, "")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CharInput in input mode resolves to AppendChar`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.CharInput('a'), InputMode.WorkflowId, "")
|
||||
assertEquals(Action.AppendChar('a'), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Enter on blank input resolves to null`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.WorkflowId, "")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Enter on non-blank input resolves to SubmitInput`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.WorkflowId, "test")
|
||||
assertEquals(Action.SubmitInput, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Escape in input mode resolves to CancelInput`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Escape, InputMode.WorkflowId, "")
|
||||
assertEquals(Action.CancelInput, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Backspace in input mode resolves to Backspace action`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Backspace, InputMode.WorkflowId, "")
|
||||
assertEquals(Action.Backspace, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Quit in None mode resolves to Quit`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Quit, InputMode.None, "")
|
||||
assertEquals(Action.Quit, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Quit in input mode resolves to null`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Quit, InputMode.WorkflowId, "")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Cancel in None mode resolves to CancelSelectedSession`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Cancel, InputMode.None, "")
|
||||
assertEquals(Action.CancelSelectedSession, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Approve in None mode resolves to ApproveActive`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Approve, InputMode.None, "")
|
||||
assertEquals(Action.ApproveActive, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Reject in None mode resolves to RejectActive`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Reject, InputMode.None, "")
|
||||
assertEquals(Action.RejectActive, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Steer in None mode resolves to OpenSteeringPrompt`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Steer, InputMode.None, "")
|
||||
assertEquals(Action.OpenSteeringPrompt, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavUp in None mode resolves to NavigateUp`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.NavUp, InputMode.None, "")
|
||||
assertEquals(Action.NavigateUp, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavDown in None mode resolves to NavigateDown`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.NavDown, InputMode.None, "")
|
||||
assertEquals(Action.NavigateDown, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Filter in None mode resolves to OpenFilter`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Filter, InputMode.None, "")
|
||||
assertEquals(Action.OpenFilter, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Enter on whitespace-only input resolves to null`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.WorkflowId, " ")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CharInput in None mode resolves to null`() {
|
||||
val result = KeyResolver.resolve(KeyEvent.CharInput('x'), InputMode.None, "")
|
||||
assertNull(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ApprovalDecision
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.ApprovalInfo
|
||||
import com.correx.apps.tui.state.ApprovalState
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ApprovalReducerTest {
|
||||
|
||||
private val activeInfo = ApprovalInfo(
|
||||
requestId = "req-1",
|
||||
sessionId = "sess-1",
|
||||
tier = "HIGH",
|
||||
riskSummary = "HIGH",
|
||||
toolName = "bash",
|
||||
preview = null,
|
||||
)
|
||||
|
||||
private fun reduce(
|
||||
approval: ApprovalState = ApprovalState(),
|
||||
inputMode: InputMode = InputMode.None,
|
||||
action: Action,
|
||||
) = ApprovalReducer.reduce(approval, inputMode, action)
|
||||
|
||||
@Test
|
||||
fun `ApproveActive emits APPROVE response and clears active`() {
|
||||
val (state, effects) = reduce(
|
||||
approval = ApprovalState(active = activeInfo),
|
||||
action = Action.ApproveActive,
|
||||
)
|
||||
assertNull(state.active)
|
||||
assertEquals(1, effects.size)
|
||||
val effect = effects[0] as Effect.SendWs
|
||||
val msg = effect.message as ClientMessage.ApprovalResponse
|
||||
assertEquals(ApprovalDecision.APPROVE, msg.decision)
|
||||
assertEquals("req-1", msg.requestId.value)
|
||||
assertNull(msg.steeringNote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApproveActive with no active approval is no-op`() {
|
||||
val (state, effects) = reduce(action = Action.ApproveActive)
|
||||
assertNull(state.active)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RejectActive emits REJECT response and clears active`() {
|
||||
val (state, effects) = reduce(
|
||||
approval = ApprovalState(active = activeInfo),
|
||||
action = Action.RejectActive,
|
||||
)
|
||||
assertNull(state.active)
|
||||
assertEquals(1, effects.size)
|
||||
val effect = effects[0] as Effect.SendWs
|
||||
val msg = effect.message as ClientMessage.ApprovalResponse
|
||||
assertEquals(ApprovalDecision.REJECT, msg.decision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in SteeringNote mode clears active approval`() {
|
||||
val (state, effects) = reduce(
|
||||
approval = ApprovalState(active = activeInfo),
|
||||
inputMode = InputMode.SteeringNote,
|
||||
action = Action.SubmitInput,
|
||||
)
|
||||
assertNull(state.active)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in non-SteeringNote mode is no-op`() {
|
||||
val (state, _) = reduce(
|
||||
approval = ApprovalState(active = activeInfo),
|
||||
inputMode = InputMode.WorkflowId,
|
||||
action = Action.SubmitInput,
|
||||
)
|
||||
assertEquals(activeInfo, state.active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServerEventReceived ApprovalRequired sets active approval`() {
|
||||
val msg = ServerMessage.ApprovalRequired(
|
||||
sessionId = SessionId("sess-1"),
|
||||
requestId = ApprovalRequestId("req-2"),
|
||||
tier = Tier.T2,
|
||||
riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"),
|
||||
toolName = "bash",
|
||||
preview = "rm -rf /",
|
||||
)
|
||||
val (state, effects) = reduce(action = Action.ServerEventReceived(msg))
|
||||
assertEquals("req-2", state.active?.requestId)
|
||||
assertEquals("sess-1", state.active?.sessionId)
|
||||
assertEquals("bash", state.active?.toolName)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.ConnectionState
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ConnectionReducerTest {
|
||||
|
||||
private fun reduce(connection: ConnectionState = ConnectionState(), action: Action) =
|
||||
ConnectionReducer.reduce(connection, action)
|
||||
|
||||
@Test
|
||||
fun `Connected sets connected true and clears reconnection state`() {
|
||||
val initial = ConnectionState(connected = false, reconnecting = true, attempt = 3, nextRetryAtMs = 9999L)
|
||||
val (state, effects) = reduce(connection = initial, action = Action.Connected)
|
||||
assertTrue(state.connected)
|
||||
assertFalse(state.reconnecting)
|
||||
assertEquals(0, state.attempt)
|
||||
assertNull(state.nextRetryAtMs)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected sets connected false and reconnecting true`() {
|
||||
val initial = ConnectionState(connected = true)
|
||||
val (state, effects) = reduce(connection = initial, action = Action.Disconnected)
|
||||
assertFalse(state.connected)
|
||||
assertTrue(state.reconnecting)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RetryScheduled updates attempt and nextRetryAtMs`() {
|
||||
val (state, effects) = reduce(action = Action.RetryScheduled(attempt = 2, nextRetryAtMs = 12345L))
|
||||
assertTrue(state.reconnecting)
|
||||
assertEquals(2, state.attempt)
|
||||
assertEquals(12345L, state.nextRetryAtMs)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Unknown action is no-op`() {
|
||||
val initial = ConnectionState(connected = true)
|
||||
val (state, _) = reduce(connection = initial, action = Action.NavigateUp)
|
||||
assertEquals(initial, state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ApprovalDecision
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.ApprovalInfo
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.apps.tui.state.InputState
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class InputReducerTest {
|
||||
|
||||
private val noApproval: ApprovalInfo? = null
|
||||
private val activeApproval = ApprovalInfo(
|
||||
requestId = "req-1",
|
||||
sessionId = "sess-1",
|
||||
tier = "HIGH",
|
||||
riskSummary = "HIGH",
|
||||
toolName = "bash",
|
||||
preview = null,
|
||||
)
|
||||
|
||||
private fun reduce(input: InputState = InputState(), approval: ApprovalInfo? = noApproval, action: Action) =
|
||||
InputReducer.reduce(input, activeApproval = approval, action = action)
|
||||
|
||||
@Test
|
||||
fun `OpenNewSessionPrompt sets mode to WorkflowId with empty text`() {
|
||||
val (state, effects) = reduce(action = Action.OpenNewSessionPrompt)
|
||||
assertEquals(InputMode.WorkflowId, state.mode)
|
||||
assertEquals("", state.text)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenSteeringPrompt sets mode to SteeringNote when approval active`() {
|
||||
val (state, effects) = reduce(approval = activeApproval, action = Action.OpenSteeringPrompt)
|
||||
assertEquals(InputMode.SteeringNote, state.mode)
|
||||
assertEquals("", state.text)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenSteeringPrompt is no-op when no active approval`() {
|
||||
val initial = InputState()
|
||||
val (state, effects) = reduce(input = initial, approval = null, action = Action.OpenSteeringPrompt)
|
||||
assertEquals(initial, state)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AppendChar appends character to text`() {
|
||||
val (state, effects) = reduce(input = InputState(InputMode.WorkflowId, "ab"), action = Action.AppendChar('c'))
|
||||
assertEquals("abc", state.text)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Backspace drops last character`() {
|
||||
val (state, effects) = reduce(input = InputState(InputMode.WorkflowId, "abc"), action = Action.Backspace)
|
||||
assertEquals("ab", state.text)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Backspace on empty text stays empty`() {
|
||||
val (state, _) = reduce(input = InputState(InputMode.WorkflowId, ""), action = Action.Backspace)
|
||||
assertEquals("", state.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelInput resets to None with empty text`() {
|
||||
val (state, effects) = reduce(input = InputState(InputMode.WorkflowId, "hello"), action = Action.CancelInput)
|
||||
assertEquals(InputMode.None, state.mode)
|
||||
assertEquals("", state.text)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in WorkflowId mode emits StartSession effect`() {
|
||||
val (state, effects) = reduce(
|
||||
input = InputState(InputMode.WorkflowId, "my-workflow"),
|
||||
action = Action.SubmitInput,
|
||||
)
|
||||
assertEquals(InputMode.None, state.mode)
|
||||
assertEquals(1, effects.size)
|
||||
val effect = effects[0] as Effect.SendWs
|
||||
val msg = effect.message as ClientMessage.StartSession
|
||||
assertEquals("my-workflow", msg.workflowId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in WorkflowId mode with blank text emits no effect`() {
|
||||
val (state, effects) = reduce(
|
||||
input = InputState(InputMode.WorkflowId, " "),
|
||||
action = Action.SubmitInput,
|
||||
)
|
||||
assertEquals(InputMode.None, state.mode)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in SteeringNote mode with active approval emits ApprovalResponse STEER`() {
|
||||
val (state, effects) = reduce(
|
||||
input = InputState(InputMode.SteeringNote, "steer this way"),
|
||||
approval = activeApproval,
|
||||
action = Action.SubmitInput,
|
||||
)
|
||||
assertEquals(InputMode.None, state.mode)
|
||||
assertEquals(1, effects.size)
|
||||
val effect = effects[0] as Effect.SendWs
|
||||
val msg = effect.message as ClientMessage.ApprovalResponse
|
||||
assertEquals(ApprovalDecision.STEER, msg.decision)
|
||||
assertEquals("steer this way", msg.steeringNote)
|
||||
assertEquals("req-1", msg.requestId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in Filter mode clears input with no effects`() {
|
||||
val (state, effects) = reduce(
|
||||
input = InputState(InputMode.Filter, "myfilter"),
|
||||
action = Action.SubmitInput,
|
||||
)
|
||||
assertEquals(InputMode.None, state.mode)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.apps.tui.state.InputState
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RootReducerTest {
|
||||
|
||||
private val fixedClock: () -> Long = { 1000L }
|
||||
|
||||
@Test
|
||||
fun `SubmitInput WorkflowId emits StartSession effect and resets input`() {
|
||||
val initial = TuiState(input = InputState(InputMode.WorkflowId, "my-workflow"))
|
||||
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
|
||||
assertEquals(InputMode.None, state.input.mode)
|
||||
assertEquals("", state.input.text)
|
||||
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
|
||||
assertEquals(1, wsEffects.size)
|
||||
val msg = wsEffects[0].message as ClientMessage.StartSession
|
||||
assertEquals("my-workflow", msg.workflowId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Connected action updates connection state without touching others`() {
|
||||
val initial = TuiState()
|
||||
val (state, effects) = RootReducer.reduce(initial, Action.Connected, fixedClock)
|
||||
assertTrue(state.connection.connected)
|
||||
assertTrue(effects.isEmpty())
|
||||
assertEquals(initial.input, state.input)
|
||||
assertEquals(initial.sessions, state.sessions)
|
||||
assertEquals(initial.approval, state.approval)
|
||||
assertEquals(initial.provider, state.provider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateUp with sessions adjusts selected id`() {
|
||||
val state0 = TuiState()
|
||||
val startMsg = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
|
||||
sessionId = com.correx.core.events.types.SessionId("s1"),
|
||||
workflowId = "wf",
|
||||
)
|
||||
val (state1, _) = RootReducer.reduce(state0, Action.ServerEventReceived(startMsg), fixedClock)
|
||||
val startMsg2 = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
|
||||
sessionId = com.correx.core.events.types.SessionId("s2"),
|
||||
workflowId = "wf",
|
||||
)
|
||||
val (state2, _) = RootReducer.reduce(state1, Action.ServerEventReceived(startMsg2), fixedClock)
|
||||
// selected is s1 (first); navigating down should move to s2
|
||||
val (state3, _) = RootReducer.reduce(state2, Action.NavigateDown, fixedClock)
|
||||
assertEquals("s2", state3.sessions.selectedId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AppendChar followed by Backspace returns to original text`() {
|
||||
val initial = TuiState(input = InputState(InputMode.WorkflowId, "ab"))
|
||||
val (s1, _) = RootReducer.reduce(initial, Action.AppendChar('c'), fixedClock)
|
||||
val (s2, _) = RootReducer.reduce(s1, Action.Backspace, fixedClock)
|
||||
assertEquals("ab", s2.input.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.apps.tui.state.SessionSummary
|
||||
import com.correx.apps.tui.state.SessionsState
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SessionsReducerTest {
|
||||
|
||||
private val fixedClock: () -> Long = { 1000L }
|
||||
|
||||
private fun session(id: String) = SessionSummary(
|
||||
id = id,
|
||||
status = "ACTIVE",
|
||||
workflowId = "wf",
|
||||
lastEventAt = 0L,
|
||||
currentStage = null,
|
||||
lastOutput = null,
|
||||
)
|
||||
|
||||
private fun reduce(
|
||||
sessions: SessionsState = SessionsState(),
|
||||
inputMode: InputMode = InputMode.None,
|
||||
inputText: String = "",
|
||||
action: Action,
|
||||
) = SessionsReducer.reduce(sessions, inputMode, inputText, action, fixedClock)
|
||||
|
||||
@Test
|
||||
fun `NavigateUp wraps from top to bottom`() {
|
||||
val s = SessionsState(sessions = listOf(session("a"), session("b"), session("c")), selectedId = "a")
|
||||
val (state, effects) = reduce(sessions = s, action = Action.NavigateUp)
|
||||
assertEquals("c", state.selectedId)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateDown advances selection`() {
|
||||
val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "a")
|
||||
val (state, _) = reduce(sessions = s, action = Action.NavigateDown)
|
||||
assertEquals("b", state.selectedId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateDown wraps from bottom to top`() {
|
||||
val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "b")
|
||||
val (state, _) = reduce(sessions = s, action = Action.NavigateDown)
|
||||
assertEquals("a", state.selectedId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in Filter mode copies inputText into filter`() {
|
||||
val s = SessionsState()
|
||||
val (state, effects) = reduce(
|
||||
sessions = s, inputMode = InputMode.Filter, inputText = "myfilter", action = Action.SubmitInput,
|
||||
)
|
||||
assertEquals("myfilter", state.filter)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelInput in Filter mode clears filter`() {
|
||||
val s = SessionsState(filter = "something")
|
||||
val (state, effects) = reduce(sessions = s, inputMode = InputMode.Filter, action = Action.CancelInput)
|
||||
assertEquals("", state.filter)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelInput in non-Filter mode is no-op`() {
|
||||
val s = SessionsState(filter = "something")
|
||||
val (state, _) = reduce(sessions = s, inputMode = InputMode.WorkflowId, action = Action.CancelInput)
|
||||
assertEquals("something", state.filter)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServerEventReceived SessionStarted appends session and sets selection`() {
|
||||
val msg = ServerMessage.SessionStarted(sessionId = SessionId("s1"), workflowId = "wf1")
|
||||
val (state, effects) = reduce(action = Action.ServerEventReceived(msg))
|
||||
assertEquals(1, state.sessions.size)
|
||||
assertEquals("s1", state.sessions[0].id)
|
||||
assertEquals("s1", state.selectedId)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServerEventReceived SessionStarted preserves existing selection`() {
|
||||
val existing = SessionsState(sessions = listOf(session("existing")), selectedId = "existing")
|
||||
val msg = ServerMessage.SessionStarted(sessionId = SessionId("new"), workflowId = "wf")
|
||||
val (state, _) = reduce(sessions = existing, action = Action.ServerEventReceived(msg))
|
||||
assertEquals("existing", state.selectedId)
|
||||
assertEquals(2, state.sessions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServerEventReceived SessionCompleted updates status to COMPLETED`() {
|
||||
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
|
||||
val msg = ServerMessage.SessionCompleted(sessionId = SessionId("s1"))
|
||||
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
|
||||
assertEquals("COMPLETED", state.sessions[0].status)
|
||||
assertEquals(1000L, state.sessions[0].lastEventAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServerEventReceived SessionFailed updates status to FAILED`() {
|
||||
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
|
||||
val msg = ServerMessage.SessionFailed(sessionId = SessionId("s1"), reason = "oops")
|
||||
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
|
||||
assertEquals("FAILED", state.sessions[0].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelSelectedSession emits CancelSession effect`() {
|
||||
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
|
||||
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
|
||||
assertEquals(1, effects.size)
|
||||
val effect = effects[0] as Effect.SendWs
|
||||
val msg = effect.message as com.correx.apps.server.protocol.ClientMessage.CancelSession
|
||||
assertEquals("s1", msg.sessionId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelSelectedSession with no selection emits no effects`() {
|
||||
val s = SessionsState(sessions = listOf(session("s1")), selectedId = null)
|
||||
val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user