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 }
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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,
|
||||
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
|
||||
},
|
||||
) {
|
||||
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))
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -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) + "┤"
|
||||
}
|
||||
@@ -18,11 +18,11 @@ fun SessionList(sessions: List<SessionSummary>, selectedId: String?) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -3,15 +3,15 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
id 'org.jetbrains.kotlin.jvm' version '2.0.21' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.0.21' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false
|
||||
id 'org.jetbrains.kotlin.jvm' version '2.2.10' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.10' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.2.10' apply false
|
||||
id "io.gitlab.arturbosch.detekt" version "1.23.7"
|
||||
id "org.jetbrains.kotlinx.kover" version "0.8.3"
|
||||
}
|
||||
|
||||
ext {
|
||||
kotlin_version = '2.0.21'
|
||||
kotlin_version = '2.2.10'
|
||||
kotlinx_coroutines_version = '1.9.0'
|
||||
kotlinx_serialization_version = '1.7.3'
|
||||
kotlinx_datetime_version = '0.6.2'
|
||||
|
||||
+21
-1
@@ -31,12 +31,17 @@ class DefaultSessionOrchestrator(
|
||||
graph: WorkflowGraph,
|
||||
config: OrchestrationConfig,
|
||||
): WorkflowResult {
|
||||
System.err.println("[Orchestrator] session=${sessionId.value} workflow=${graph.id} start=${graph.start.value}")
|
||||
emitWorkflowStarted(sessionId, graph, config)
|
||||
val base = ExecutionContext(graph, sessionId, 0, graph.start, config, null, null)
|
||||
return step(base.enrich())
|
||||
}
|
||||
|
||||
private tailrec suspend fun step(ctx: EnrichedExecutionContext): WorkflowResult {
|
||||
System.err.println(
|
||||
"[Orchestrator] step session=${ctx.sessionId.value} stage=${ctx.currentStageId.value} " +
|
||||
"stageCount=${ctx.stageCount}"
|
||||
)
|
||||
if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId)
|
||||
|
||||
val enriched = EnrichedExecutionContext(
|
||||
@@ -49,7 +54,17 @@ class DefaultSessionOrchestrator(
|
||||
session = repositories.sessionRepository.getSession(ctx.sessionId),
|
||||
)
|
||||
|
||||
return when (val decision = resolveTransition(enriched.graph, enriched.sessionId, enriched.currentStageId)) {
|
||||
val stageConfig = enriched.graph.stages[enriched.currentStageId]
|
||||
val stageArtifacts = stageConfig?.produces
|
||||
?.let { repositories.artifactRepository.getByIds(enriched.sessionId, it) }
|
||||
?: emptyMap()
|
||||
|
||||
val decision = resolveTransition(enriched.graph, enriched.sessionId, enriched.currentStageId, stageArtifacts)
|
||||
System.err.println(
|
||||
"[Orchestrator] transition session=${enriched.sessionId.value} " +
|
||||
"stage=${enriched.currentStageId.value} decision=${decision::class.simpleName}"
|
||||
)
|
||||
return when (decision) {
|
||||
is TransitionDecision.Move -> when (val r = executeMove(enriched, decision)) {
|
||||
is StepResult.Continue -> step(r.ctx)
|
||||
is StepResult.Terminal -> r.result
|
||||
@@ -100,6 +115,7 @@ class DefaultSessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
System.err.println("[Orchestrator] executeStage session=${ctx.sessionId.value} stage=${nextStageId.value}")
|
||||
return when (val result = executeStage(ctx.sessionId, nextStageId, ctx.graph, ctx.session, ctx.config)) {
|
||||
is StageExecutionResult.Success -> StepResult.Continue(
|
||||
ctx.copy(
|
||||
@@ -109,6 +125,10 @@ class DefaultSessionOrchestrator(
|
||||
)
|
||||
|
||||
is StageExecutionResult.Failure -> {
|
||||
System.err.println(
|
||||
"[Orchestrator] stage failed session=${ctx.sessionId.value} " +
|
||||
"stage=${nextStageId.value} reason=${result.reason} retryable=${result.retryable}"
|
||||
)
|
||||
val shouldRetry = retryCoordinator.shouldRetry(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = nextStageId,
|
||||
|
||||
+61
-2
@@ -5,8 +5,11 @@ import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.context.builder.ContextPackBuilder
|
||||
import com.correx.core.context.model.ContextEntry
|
||||
import com.correx.core.context.model.ContextLayer
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.events.types.ContextEntryId
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
@@ -23,9 +26,11 @@ import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
@@ -43,6 +48,7 @@ import com.correx.core.risk.RiskContext
|
||||
import com.correx.core.risk.toApprovalTier
|
||||
import com.correx.core.sessions.Session
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.evaluation.PromptResolver
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
@@ -67,6 +73,7 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
"TooGenericExceptionCaught",
|
||||
"TooManyFunctions",
|
||||
"NestedBlockDepth",
|
||||
"LongMethod",
|
||||
)
|
||||
abstract class SessionOrchestrator(
|
||||
repositories: OrchestratorRepositories,
|
||||
@@ -78,6 +85,7 @@ abstract class SessionOrchestrator(
|
||||
private val inferenceRouter: InferenceRouter = engines.inferenceRouter
|
||||
private val validationPipeline: ValidationPipeline = engines.validationPipeline
|
||||
private val riskAssessor: RiskAssessor = engines.riskAssessor
|
||||
private val promptResolver: PromptResolver = engines.promptResolver
|
||||
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||
internal abstract val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean>
|
||||
@@ -104,11 +112,35 @@ abstract class SessionOrchestrator(
|
||||
val stageConfig = requireNotNull(graph.stages[stageId]) {
|
||||
"Stage '${stageId.value}' not declared in workflow graph"
|
||||
}
|
||||
val promptEntries = stageConfig.metadata["prompt"]
|
||||
?.let { path ->
|
||||
runCatching { promptResolver.resolve(path) }
|
||||
.onFailure {
|
||||
System.err.println(
|
||||
"[SessionOrchestrator] stage=${stageId.value}: failed to load prompt '$path': ${it.message}"
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { text ->
|
||||
listOf(
|
||||
ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L0,
|
||||
content = text,
|
||||
sourceType = "systemPrompt",
|
||||
sourceId = stageId.value,
|
||||
tokenEstimate = text.length / 4,
|
||||
),
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
val contextPack = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
entries = emptyList(),
|
||||
entries = promptEntries,
|
||||
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
||||
)
|
||||
|
||||
@@ -143,6 +175,10 @@ abstract class SessionOrchestrator(
|
||||
timeoutMs: Long,
|
||||
): InferenceResult {
|
||||
val provider = inferenceRouter.route(stageId, stageConfig.requiredCapabilities)
|
||||
System.err.println(
|
||||
"[Orchestrator] inference session=${sessionId.value} stage=${stageId.value} " +
|
||||
"provider=${provider.id.value} timeoutMs=$timeoutMs"
|
||||
)
|
||||
val requestId = InferenceRequestId(UUID.randomUUID().toString())
|
||||
val request = InferenceRequest(
|
||||
requestId = requestId,
|
||||
@@ -158,6 +194,10 @@ abstract class SessionOrchestrator(
|
||||
|
||||
return try {
|
||||
val response = withTimeout(timeoutMs) { provider.infer(request) }
|
||||
System.err.println(
|
||||
"[Orchestrator] inference done session=${sessionId.value} stage=${stageId.value} " +
|
||||
"tokens=${response.tokensUsed} latencyMs=${response.latencyMs}"
|
||||
)
|
||||
|
||||
if (isCancelled(sessionId)) return InferenceResult.Cancelled
|
||||
|
||||
@@ -209,8 +249,9 @@ abstract class SessionOrchestrator(
|
||||
graph: WorkflowGraph,
|
||||
sessionId: SessionId,
|
||||
currentStageId: StageId,
|
||||
artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
|
||||
): TransitionDecision {
|
||||
val ctx = EvaluationContext(sessionId, currentStageId, emptyMap())
|
||||
val ctx = EvaluationContext(sessionId, currentStageId, artifacts = artifacts)
|
||||
return transitionResolver.resolve(graph, ctx)
|
||||
}
|
||||
|
||||
@@ -244,6 +285,10 @@ abstract class SessionOrchestrator(
|
||||
terminalStageId: StageId,
|
||||
stageCount: Int,
|
||||
): WorkflowResult.Completed {
|
||||
System.err.println(
|
||||
"[Orchestrator] COMPLETED session=${sessionId.value} " +
|
||||
"terminalStage=${terminalStageId.value} stages=$stageCount"
|
||||
)
|
||||
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Completed(sessionId, terminalStageId)
|
||||
@@ -255,6 +300,10 @@ abstract class SessionOrchestrator(
|
||||
reason: String,
|
||||
retryExhausted: Boolean,
|
||||
): WorkflowResult.Failed {
|
||||
System.err.println(
|
||||
"[Orchestrator] FAILED session=${sessionId.value} stage=${stageId.value} " +
|
||||
"reason=$reason retryExhausted=$retryExhausted"
|
||||
)
|
||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Failed(sessionId, reason, retryExhausted)
|
||||
@@ -264,6 +313,7 @@ abstract class SessionOrchestrator(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
): WorkflowResult.Cancelled {
|
||||
System.err.println("[Orchestrator] CANCELLED session=${sessionId.value} stage=${stageId.value}")
|
||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, "CANCELLED", retryExhausted = false))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Cancelled(sessionId)
|
||||
@@ -299,6 +349,7 @@ abstract class SessionOrchestrator(
|
||||
stageId: StageId,
|
||||
outcome: ValidationOutcome.NeedsApproval,
|
||||
): StageExecutionResult {
|
||||
System.err.println("[Orchestrator] approval required session=${sessionId.value} stage=${stageId.value}")
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
val domainRequest = buildApprovalRequest(sessionId, stageId, outcome)
|
||||
|
||||
@@ -319,7 +370,15 @@ abstract class SessionOrchestrator(
|
||||
pendingApprovals[domainRequest.id] = deferred
|
||||
|
||||
return try {
|
||||
System.err.println(
|
||||
"[Orchestrator] awaiting approval session=${sessionId.value} " +
|
||||
"requestId=${domainRequest.id.value}"
|
||||
)
|
||||
val decision = deferred.await()
|
||||
System.err.println(
|
||||
"[Orchestrator] approval resolved session=${sessionId.value} " +
|
||||
"approved=${decision.isApproved}"
|
||||
)
|
||||
emitDecisionResolved(sessionId, domainRequest, decision)
|
||||
if (decision.isApproved) {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# TUI Refactor — Progress Log
|
||||
|
||||
**Plan:** `docs/plans/2026-05-17-tui-refactor.md`
|
||||
**Last update:** 2026-05-17
|
||||
**Mode:** running with `/implement` (spec + quality review skipped per user instruction)
|
||||
**Base SHA at start:** `58670a6` (no commits made yet — all changes still in working tree)
|
||||
|
||||
---
|
||||
|
||||
## Completed
|
||||
|
||||
### Task 1.1 — Upgrade Mosaic to 0.18.0 ✅
|
||||
- `apps/tui/build.gradle`: mosaic-runtime 0.13.0 → 0.18.0
|
||||
- **Scope creep:** Mosaic 0.18 requires Kotlin 2.2.10, so root `build.gradle` was bumped 2.0.21 → 2.2.10 globally.
|
||||
- Side-effect: 9 new `MaxLineLength` detekt failures in `core/kernel/orchestration/{SessionOrchestrator,DefaultSessionOrchestrator}.kt` — fixed by wrapping long `System.err.println` calls. Added `LongMethod` to existing `@SuppressWarnings` on `SessionOrchestrator`.
|
||||
- `./gradlew check` green.
|
||||
|
||||
### Task 1.2 — Action sealed type + KeyResolver ✅
|
||||
- Created `apps/tui/.../input/Action.kt` (14 variants), `input/KeyResolver.kt`, `test/.../input/KeyResolverTest.kt` (18 tests).
|
||||
- Added `Filter` to `KeyEvent.kt`.
|
||||
- Added JUnit 5 + kotlin-test deps + `useJUnitPlatform()` to `apps/tui/build.gradle`.
|
||||
|
||||
### Task 1.3 — Replace stdin reader with `Modifier.onKeyEvent` ✅
|
||||
- Deleted `keyChannel`, `readKeys`, `charToKeyEvent`, `handleKeys`, `handleInputModeKey`.
|
||||
- Created `input/MosaicKeyMapper.kt` mapping `com.jakewharton.mosaic.layout.KeyEvent { key, alt, ctrl, shift }` → domain `KeyEvent`.
|
||||
- `Column` now has `Modifier.onKeyEvent { ... }` at root; dispatch path = MosaicKeyMapper → KeyResolver → `applyAction`.
|
||||
- `applyAction(action, state, wsClient, scope): TuiState` is pure on state; ws sends launched via `scope.launch` from within (transitional — Task 2.3 replaces it).
|
||||
- `TuiNavigation.kt` shrunk: kept `navigateUp`/`navigateDown`, dropped `parseEscapeSequence` + `handleCancel`.
|
||||
|
||||
### Task 1.4 — Steering note inside InputMode ✅
|
||||
- `InputMode` final shape: `None, WorkflowId, SteeringNote, Filter`.
|
||||
- `Action.OpenSteeringPrompt` → switches to `SteeringNote` only if `state.approval.active != null`.
|
||||
- `Action.SubmitInput` with mode=SteeringNote → sends `ApprovalResponse(decision=STEER, steeringNote=text)`, clears approval.
|
||||
- `InputBar` labels updated. `handleSteer` deleted. No `System.console` anywhere in `apps/tui`.
|
||||
|
||||
### Task 2.1 — Split TuiState into slices ✅
|
||||
- New: `state/{ConnectionState,SessionsState,InputState,ApprovalState,ProviderState}.kt`.
|
||||
- `TuiState` is now composition of the five slices with defaults.
|
||||
- Field mapping applied across `TuiApp.kt`, `TuiNavigation.kt`, `StateReducer.kt`, `components/StatusBar.kt`:
|
||||
- `state.connected/reconnecting` → `state.connection.*`
|
||||
- `state.sessions` (list) → `state.sessions.sessions`; `state.selectedSessionId` → `state.sessions.selectedId`
|
||||
- `state.activeApproval` → `state.approval.active`
|
||||
- `state.inputText/inputMode` → `state.input.text/mode`
|
||||
- `state.providerId/providerStatus` → `state.provider.id/status`
|
||||
|
||||
### Task 2.2 — Pure RootReducer + Effect ✅
|
||||
- New: `reducer/{Effect,InputReducer,SessionsReducer,ApprovalReducer,ConnectionReducer,ProviderReducer,ServerMessageReducer,RootReducer}.kt`.
|
||||
- `Action` extended: `ServerEventReceived(ServerMessage)`, `Connected`, `Disconnected`, `RetryScheduled(attempt, nextRetryAtMs)`.
|
||||
- All slice reducers return `Pair<SliceState, List<Effect>>`. Default arm = `state to emptyList()`.
|
||||
- 39 new tests added (55 total in module).
|
||||
- **NOT YET WIRED** into `TuiApp.kt` — that is Task 2.3. The old `applyAction`/`applyServerMessage` paths still run.
|
||||
- Decisions deviating from plan: dropped unused `selectedSessionId` param on `InputReducer.reduce`; dropped unused `clock` param on `ConnectionReducer.reduce`. Old `StateReducer.kt` left in place alongside new `ServerMessageReducer.kt` (no callers of `ServerMessageReducer` yet).
|
||||
|
||||
---
|
||||
|
||||
## Pending — resume here
|
||||
|
||||
### Task 2.3 — EffectDispatcher + wire RootReducer into TuiApp 🚧 NEXT
|
||||
- Create `reducer/EffectDispatcher.kt`.
|
||||
- Rewrite `ws/TuiWsClient.kt`: expose `messages: SharedFlow<ServerMessage>` and `connection: SharedFlow<ConnectionEvent>`; remove suspend-callback constructor params. Add `sealed interface ConnectionEvent { Connected, Disconnected, RetryScheduled(attempt, nextRetryAtMs) }`.
|
||||
- In `TuiApp`: single `dispatch(action)` → `RootReducer.reduce` → `Snapshot.withMutableSnapshot { state = next }` → trySend each `Effect` to a `Channel<Effect>`.
|
||||
- Launch coroutines: `ws.connect()`, collect `ws.messages` → `dispatch(ServerEventReceived(it))`, collect `ws.connection` → `dispatch(it.toAction())`, drain effects channel via `EffectDispatcher`.
|
||||
- Delete old `applyAction`/`applyServerMessage` (and `StateReducer.kt` if unused after the cut).
|
||||
- Smoke-test: `n` → workflow id prompt → submit → session starts; approval flow; steering note.
|
||||
|
||||
### Task 3.1 — `Panel` composable + `TerminalSize`
|
||||
- Create `components/Panel.kt` (`@Composable fun Panel(title: String?, content: @Composable () -> Unit)`).
|
||||
- Create `components/TerminalSize.kt` (data class + `staticCompositionLocalOf { TerminalSize(80, 24) }`).
|
||||
- Do not wire yet.
|
||||
|
||||
### Task 3.2 — Migrate components to `Panel`
|
||||
- Wrap each section in `TuiApp` with `Panel(title=…) { … }`.
|
||||
- Drop every `BOX_WIDTH` constant and hand-padding from `StatusBar`, `SessionList`, `ActiveSession`, `ApprovalPanel`, `InputBar`.
|
||||
- `grep -r "BOX_WIDTH" apps/tui` must return nothing.
|
||||
|
||||
### Task 3.3 — Responsive terminal width
|
||||
- Add `readTerminalSize()` (stty fallback, default 80×24) in `TerminalSize.kt`.
|
||||
- 500ms polling loop in `TuiApp.runTuiApp`; wrap content in `CompositionLocalProvider(LocalTerminalSize provides …)`.
|
||||
|
||||
### Task 4.1 — Scrollable session output log
|
||||
- Extend `SessionsState`: `eventLog: Map<SessionId, ArrayDeque<LogEntry>>`, `scrollOffsetById: Map<SessionId, Int>`.
|
||||
- New `LogEntry(timestampMs, kind, text)`.
|
||||
- `SessionsReducer`: on `ServerEventReceived` for InferenceCompleted/ToolCompleted/StageStarted/StageCompleted/StageFailed, append entry (cap 200).
|
||||
- Rename `ActiveSession.kt` → `SessionOutputLog.kt`; render last N entries with scroll offset.
|
||||
- Add `Action.ScrollLogUp/Down`; introduce `FocusState { SessionList, OutputLog }`, Tab toggles. Map `NavUp/NavDown` to scroll when focus is on log.
|
||||
|
||||
### Task 4.2 — Session filter via `InputMode.Filter`
|
||||
- `/` enters Filter mode (already wired in resolver). On `SubmitInput` mode=Filter, copy text into `sessions.filter`. On `CancelInput` mode=Filter, clear filter.
|
||||
- `SessionList` renders only sessions whose `workflowId` contains `filter` (case-insensitive).
|
||||
|
||||
### Task 4.3 — Reconnect feedback
|
||||
- In `TuiWsClient.connect`, before backoff `delay`, emit `ConnectionEvent.RetryScheduled(attempt = ++count, nextRetryAtMs = clock() + delayMs)`.
|
||||
- `ConnectionReducer.RetryScheduled` already handles state; `Connected` resets attempt to 0.
|
||||
- `StatusBar` formats `nextRetryAtMs - clock()` as countdown.
|
||||
|
||||
### Task 5.1 — Coverage gate
|
||||
- Add kover verify rule to `apps/tui/build.gradle`: minBound 70 on `com.correx.apps.tui.reducer.*` + `com.correx.apps.tui.input.*`. Confirm syntax against current kover version.
|
||||
|
||||
### Task 5.2 — Final cleanup pass
|
||||
- Audit every `@Suppress` under `apps/tui/src/main`; either fix or document.
|
||||
- Update `docs/refactor.md` 2026-05-17 entries to `[done]`.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
```
|
||||
[done] 1.1 → 1.2 → 1.3 → 1.4 → 2.1 → 2.2
|
||||
[next] 2.3 → 3.1 → 3.2 → 3.3 → {4.1, 4.2, 4.3} → 5.1 → 5.2
|
||||
```
|
||||
|
||||
## Notes for the next session
|
||||
- Working tree currently has no commits since `58670a6`; everything is staged-or-modified. Consider committing per task on resume.
|
||||
- Kotlin is now 2.2.10 repo-wide; expect occasional `experimental context receivers` warnings (`-Xcontext-receivers`) — future Kotlin will error on this.
|
||||
- Mosaic 0.18 `KeyEvent` lives at `com.jakewharton.mosaic.layout.KeyEvent` with fields `key: String, alt, ctrl, shift`. `onKeyEvent` is `com.jakewharton.mosaic.layout.onKeyEvent`.
|
||||
- All `EventPayload` subclasses must be registered in `core/events/.../serialization/Serialization.kt` — relevant for Task 4.1 if any new event types are added (none currently planned).
|
||||
Reference in New Issue
Block a user