From b95135eb3ba92c46b4bf66d78cd9bbcabdea31d2 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 17 May 2026 02:57:54 +0400 Subject: [PATCH] =?UTF-8?q?refactor(tui):=20event-loop=20foundation=20?= =?UTF-8?q?=E2=80=94=20actions,=20reducers,=20effects,=20Panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- apps/tui/build.gradle | 21 +- .../kotlin/com/correx/apps/tui/KeyEvent.kt | 5 + .../com/correx/apps/tui/StateReducer.kt | 89 ------- .../main/kotlin/com/correx/apps/tui/TuiApp.kt | 244 ++++++------------ .../com/correx/apps/tui/TuiNavigation.kt | 19 ++ .../apps/tui/components/ActiveSession.kt | 2 +- .../apps/tui/components/ApprovalPanel.kt | 5 +- .../correx/apps/tui/components/InputBar.kt | 12 +- .../com/correx/apps/tui/components/Panel.kt | 22 ++ .../correx/apps/tui/components/SessionList.kt | 6 +- .../correx/apps/tui/components/StatusBar.kt | 12 +- .../apps/tui/components/TerminalSize.kt | 18 ++ .../com/correx/apps/tui/input/Action.kt | 23 ++ .../com/correx/apps/tui/input/KeyResolver.kt | 32 +++ .../correx/apps/tui/input/MosaicKeyMapper.kt | 29 +++ .../apps/tui/reducer/ApprovalReducer.kt | 71 +++++ .../apps/tui/reducer/ConnectionReducer.kt | 21 ++ .../com/correx/apps/tui/reducer/Effect.kt | 8 + .../apps/tui/reducer/EffectDispatcher.kt | 15 ++ .../correx/apps/tui/reducer/InputReducer.kt | 61 +++++ .../apps/tui/reducer/ProviderReducer.kt | 21 ++ .../correx/apps/tui/reducer/RootReducer.kt | 26 ++ .../apps/tui/reducer/ServerMessageReducer.kt | 10 + .../apps/tui/reducer/SessionsReducer.kt | 138 ++++++++++ .../correx/apps/tui/state/ApprovalState.kt | 5 + .../correx/apps/tui/state/ConnectionState.kt | 8 + .../com/correx/apps/tui/state/InputState.kt | 6 + .../correx/apps/tui/state/ProviderState.kt | 6 + .../correx/apps/tui/state/SessionsState.kt | 7 + .../com/correx/apps/tui/state/TuiState.kt | 15 +- .../com/correx/apps/tui/ws/ConnectionEvent.kt | 7 + .../com/correx/apps/tui/ws/TuiWsClient.kt | 29 ++- .../correx/apps/tui/input/KeyResolverTest.kt | 117 +++++++++ .../apps/tui/reducer/ApprovalReducerTest.kt | 108 ++++++++ .../apps/tui/reducer/ConnectionReducerTest.kt | 51 ++++ .../apps/tui/reducer/InputReducerTest.kt | 128 +++++++++ .../apps/tui/reducer/RootReducerTest.kt | 65 +++++ .../apps/tui/reducer/SessionsReducerTest.kt | 133 ++++++++++ build.gradle | 8 +- .../DefaultSessionOrchestrator.kt | 22 +- .../orchestration/SessionOrchestrator.kt | 63 ++++- .../plans/2026-05-17-tui-refactor.progress.md | 115 +++++++++ 42 files changed, 1512 insertions(+), 291 deletions(-) delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/StateReducer.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/TuiNavigation.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ConnectionReducer.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ProviderReducer.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ServerMessageReducer.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/ApprovalState.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/ConnectionState.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/ProviderState.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/ws/ConnectionEvent.kt create mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt create mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt create mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ConnectionReducerTest.kt create mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt create mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt create mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt create mode 100644 docs/plans/2026-05-17-tui-refactor.progress.md diff --git a/apps/tui/build.gradle b/apps/tui/build.gradle index cf68a9f3..44f1854a 100644 --- a/apps/tui/build.gradle +++ b/apps/tui/build.gradle @@ -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" + ) +} \ No newline at end of file diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt index ead2d05d..c421efb5 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt @@ -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() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/StateReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/StateReducer.kt deleted file mode 100644 index 3eafa46b..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/StateReducer.kt +++ /dev/null @@ -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() diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt index eafe5a4d..4828bd85 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt @@ -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(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(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) { - 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, - 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() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiNavigation.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiNavigation.kt new file mode 100644 index 00000000..70327bbf --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiNavigation.kt @@ -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)) +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt index b484c00b..2dea5ece 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt @@ -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 ?: "—"}") diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt index fa0515b6..8946c363 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt @@ -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") } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt index 1d72b693..4877a722 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt @@ -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}▌") } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt new file mode 100644 index 00000000..3ecd3111 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt @@ -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) + "┤" +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt index c81ffbe6..8d7d5b3e 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt @@ -15,14 +15,14 @@ private const val SECS_PER_HOUR = 3600L fun SessionList(sessions: List, 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") } } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt index 0d04a8ce..66558962 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt @@ -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") } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt new file mode 100644 index 00000000..bd032fbd --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt @@ -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)) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt new file mode 100644 index 00000000..474756cc --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt @@ -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 +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt new file mode 100644 index 00000000..fbff18e8 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt @@ -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 + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt new file mode 100644 index 00000000..368cd334 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt @@ -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 + } + } + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt new file mode 100644 index 00000000..dafb2a02 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ApprovalReducer.kt @@ -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> = 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() + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ConnectionReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ConnectionReducer.kt new file mode 100644 index 00000000..6422acb8 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ConnectionReducer.kt @@ -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> = 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() + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt new file mode 100644 index 00000000..e46daaec --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt @@ -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 +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt new file mode 100644 index 00000000..5d132850 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt @@ -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() + } + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt new file mode 100644 index 00000000..eb2dee45 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt @@ -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> = 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() + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ProviderReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ProviderReducer.kt new file mode 100644 index 00000000..16e27983 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ProviderReducer.kt @@ -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> = 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() + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt new file mode 100644 index 00000000..9fc9e5ea --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt @@ -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> { + val quitEffects: List = 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) + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ServerMessageReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ServerMessageReducer.kt new file mode 100644 index 00000000..9ed7e118 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ServerMessageReducer.kt @@ -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) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt new file mode 100644 index 00000000..6ac93843 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt @@ -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> = 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 + } + }, + ) +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ApprovalState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ApprovalState.kt new file mode 100644 index 00000000..c5bc4e24 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ApprovalState.kt @@ -0,0 +1,5 @@ +package com.correx.apps.tui.state + +data class ApprovalState( + val active: ApprovalInfo? = null, +) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ConnectionState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ConnectionState.kt new file mode 100644 index 00000000..dfae851b --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ConnectionState.kt @@ -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, +) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt new file mode 100644 index 00000000..c31f53bf --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt @@ -0,0 +1,6 @@ +package com.correx.apps.tui.state + +data class InputState( + val mode: InputMode = InputMode.None, + val text: String = "", +) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ProviderState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ProviderState.kt new file mode 100644 index 00000000..a5ed278e --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ProviderState.kt @@ -0,0 +1,6 @@ +package com.correx.apps.tui.state + +data class ProviderState( + val id: String = "", + val status: String = "unknown", +) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt new file mode 100644 index 00000000..db50f9c4 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt @@ -0,0 +1,7 @@ +package com.correx.apps.tui.state + +data class SessionsState( + val sessions: List = emptyList(), + val selectedId: String? = null, + val filter: String = "", +) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt index e7331491..527034a7 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt @@ -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 = 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( diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/ConnectionEvent.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/ConnectionEvent.kt new file mode 100644 index 00000000..834ad5f2 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/ConnectionEvent.kt @@ -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 +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt index 68388b18..6574fe78 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt @@ -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(extraBufferCapacity = 64) + val messages: SharedFlow = _messages.asSharedFlow() + + private val _connection = MutableSharedFlow(extraBufferCapacity = 16) + val connection: SharedFlow = _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(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) } diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt new file mode 100644 index 00000000..8cb51a7a --- /dev/null +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt @@ -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) + } +} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt new file mode 100644 index 00000000..d4c4747a --- /dev/null +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ApprovalReducerTest.kt @@ -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()) + } +} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ConnectionReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ConnectionReducerTest.kt new file mode 100644 index 00000000..578d3241 --- /dev/null +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ConnectionReducerTest.kt @@ -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) + } +} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt new file mode 100644 index 00000000..ae9c7c9f --- /dev/null +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt @@ -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()) + } +} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt new file mode 100644 index 00000000..b2703eda --- /dev/null +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt @@ -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() + 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) + } +} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt new file mode 100644 index 00000000..e29950ab --- /dev/null +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt @@ -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()) + } +} diff --git a/build.gradle b/build.gradle index af35bb84..6f5d0a50 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index 38d4ceff..562c3993 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -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, diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 5aa5bdd4..39c679c7 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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 @@ -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 = 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)) diff --git a/docs/plans/2026-05-17-tui-refactor.progress.md b/docs/plans/2026-05-17-tui-refactor.progress.md new file mode 100644 index 00000000..5d7d6c09 --- /dev/null +++ b/docs/plans/2026-05-17-tui-refactor.progress.md @@ -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>`. 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` and `connection: SharedFlow`; 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`. +- 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>`, `scrollOffsetById: Map`. +- 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).