From f6ef0288832c2d0b5543b5ec4d666760cac62781 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 26 May 2026 01:46:14 +0400 Subject: [PATCH] feat(tui): session display model with DisplayState, input history nav, and StageToolManifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the TUI around a DisplayState enum (IDLE, IN_SESSION, APPROVAL) replacing the previous InputMode-based navigation (NAVIGATE, STEER removed). Key changes: - **DisplayState model**: New `DisplayState` enum governs per-mode key resolution and UI rendering. Removes `ROUTER`/`NAVIGATE`/`STEER`/`FILTER` InputMode complexity. - **Input history navigation**: Up/Down arrows in IN_SESSION cycle through per-session input history (ARCH-HISTORY-2/3). `savedInputBuffer` preserves in-flight text. - **Input history filter**: `Tab` toggles between ROUTER and FILTER modes; history nav disabled while filtering. - **Ctrl-C → Cancel**: Rebind Ctrl-C from Quit to Cancel across all states. - **Approval flow**: `ApproveActive`/`RejectActive` moved from SessionsReducer to RootReducer via `pendingDecision`/`SubmitApprovalDecision`. Approval overlay replaced by `ApprovalSurface` component. - **Event history strip**: Replaces old overlay with a persistent `EventHistoryStrip` component, toggled via Ctrl-E. - **Background update badge**: New `backgroundUpdateCount` on SessionsState tracks events arriving for non-selected sessions (ARCH-BADGE-1). - **StageToolManifest**: New `ServerMessage.StageToolManifest` and `ToolManifestEntry` model for pre-declared tool shapes per stage (RF-3). - **Cleanup**: Removed `ActiveSession`, `ApprovalPanel`, `ToolsPanel` components. Removed `InputMode.NAVIGATE`, `InputMode.STEER`, approval overlay state fields. Various reducer simplifications (no more overlaid effects for approval responses). --- .../kotlin/com/correx/apps/server/Main.kt | 1 + .../com/correx/apps/server/ServerModule.kt | 2 + .../apps/server/bridge/SessionEventBridge.kt | 24 +++ .../com/correx/apps/server/protocol/Dtos.kt | 12 ++ .../apps/server/protocol/ServerMessage.kt | 10 + .../apps/server/ws/GlobalStreamHandler.kt | 23 +- .../server/bridge/SessionEventBridgeTest.kt | 29 ++- .../apps/server/ws/GlobalStreamHandlerTest.kt | 23 +- .../kotlin/com/correx/apps/tui/KeyEvent.kt | 6 +- .../main/kotlin/com/correx/apps/tui/TuiApp.kt | 87 ++++++-- .../apps/tui/components/ActiveSession.kt | 53 ----- .../apps/tui/components/ApprovalPanel.kt | 32 --- .../apps/tui/components/ApprovalSurface.kt | 84 ++++++++ .../apps/tui/components/EventHistoryStrip.kt | 86 ++++++++ .../correx/apps/tui/components/InputBar.kt | 117 ++++++----- .../correx/apps/tui/components/RouterPanel.kt | 38 +--- .../correx/apps/tui/components/StatusBar.kt | 49 ++++- .../correx/apps/tui/components/ToolsPanel.kt | 47 ----- .../com/correx/apps/tui/input/Action.kt | 8 +- .../com/correx/apps/tui/input/KeyResolver.kt | 107 ++++++---- .../correx/apps/tui/input/TambouiKeyMapper.kt | 18 +- .../correx/apps/tui/reducer/InputReducer.kt | 116 +++++------ .../correx/apps/tui/reducer/RootReducer.kt | 146 +++++++++++-- .../apps/tui/reducer/SessionsReducer.kt | 192 +++++++++++------ .../com/correx/apps/tui/state/DisplayState.kt | 15 ++ .../correx/apps/tui/state/SessionsState.kt | 1 + .../com/correx/apps/tui/state/TuiState.kt | 17 +- .../correx/apps/tui/input/KeyResolverTest.kt | 170 ++++++++------- .../apps/tui/input/TambouiKeyMapperTest.kt | 50 +---- .../apps/tui/reducer/InputReducerTest.kt | 92 +------- .../apps/tui/reducer/RootReducerTest.kt | 197 +++++++++++++++--- .../apps/tui/reducer/SessionsReducerTest.kt | 29 ++- 32 files changed, 1180 insertions(+), 701 deletions(-) delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/ToolsPanel.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/DisplayState.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index d1da14bb..696d8806 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -131,6 +131,7 @@ fun main() { routerFacade = routerFacade, orchestrationRepository = repositories.orchestrationRepository, approvalRepository = repositories.approvalRepository, + toolRegistry = toolRegistry, approvalConfig = correxConfig.approval, ) module.start() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 862ca1d5..cd971900 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -13,6 +13,7 @@ import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.router.RouterFacade import com.correx.core.sessions.DefaultSessionRepository +import com.correx.core.tools.registry.ToolRegistry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -32,6 +33,7 @@ class ServerModule( val routerFacade: RouterFacade, val orchestrationRepository: OrchestrationRepository, val approvalRepository: DefaultApprovalRepository, + val toolRegistry: ToolRegistry, approvalConfig: ApprovalConfig = ApprovalConfig(), // Long-lived scope owned by the module — backs the ApprovalCoordinator timers // and event-store subscription. SupervisorJob so one failure doesn't kill the diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt index 4fd047c4..5a0f08d8 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt @@ -3,18 +3,24 @@ package com.correx.apps.server.bridge import com.correx.apps.server.protocol.ApprovalDto import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.SessionStateDto +import com.correx.apps.server.protocol.StageToolDecl +import com.correx.apps.server.protocol.ToolDecl +import com.correx.apps.server.registry.WorkflowRegistry import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.artifactstore.ArtifactStore import com.correx.core.events.orchestration.OrchestrationStatus import com.correx.core.events.stores.EventStore import com.correx.core.events.types.SessionId import com.correx.core.kernel.orchestration.OrchestrationRepository +import com.correx.core.tools.registry.ToolRegistry class SessionEventBridge( private val eventStore: EventStore, private val artifactStore: ArtifactStore, private val orchestrationRepository: OrchestrationRepository, private val approvalRepository: DefaultApprovalRepository, + private val workflowRegistry: WorkflowRegistry, + private val toolRegistry: ToolRegistry, private val send: suspend (ServerMessage) -> Unit, ) { suspend fun replaySnapshot() { @@ -46,6 +52,24 @@ class SessionEventBridge( lastSequence = lastGlobal, lastSessionSequence = lastSession, )) + + // Emit StageToolManifest for this session (ARCH-TOOL-1) + val graph = workflowRegistry.find(orchState.workflowId) + if (graph != null) { + val stages = graph.stages.map { (stageId, stageConfig) -> + val tools = stageConfig.allowedTools.mapNotNull { toolName -> + toolRegistry.resolve(toolName)?.let { tool -> + ToolDecl(name = tool.name, tier = tool.tier.level) + } + } + StageToolDecl(stageId = stageId.value, tools = tools) + } + send(ServerMessage.StageToolManifest( + sessionId = sessionId, + workflowId = orchState.workflowId, + stages = stages, + )) + } } send(ServerMessage.SnapshotComplete) } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt index 7ef407cc..a824d59b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt @@ -40,3 +40,15 @@ enum class PauseReason { APPROVAL_PENDING, USER_REQUESTED, } + +@Serializable +data class StageToolDecl( + val stageId: String, + val tools: List, +) + +@Serializable +data class ToolDecl( + val name: String, + val tier: Int, +) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index ba7f94bc..73ae0dad 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -211,6 +211,16 @@ sealed interface ServerMessage { // -- System / infra -- + @Serializable + @SerialName("stage.tool_manifest") + data class StageToolManifest( + val sessionId: SessionId, + val workflowId: String, + val stages: List, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage + @Serializable @SerialName("provider.status_changed") data class ProviderStatusChanged( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 5b188938..a9713c35 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -7,6 +7,8 @@ import com.correx.apps.server.protocol.ClientMessage import com.correx.apps.server.protocol.ProtocolSerializer import com.correx.apps.server.protocol.ProviderHealthDto import com.correx.apps.server.protocol.ServerMessage +import com.correx.apps.server.protocol.StageToolDecl +import com.correx.apps.server.protocol.ToolDecl import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.NewEvent import com.correx.core.events.events.StoredEvent @@ -47,9 +49,11 @@ class GlobalStreamHandler(private val module: ServerModule) { val bridge = SessionEventBridge( eventStore = module.eventStore, artifactStore = module.artifactStore, - send = sendFrame, orchestrationRepository = module.orchestrationRepository, approvalRepository = module.approvalRepository, + workflowRegistry = module.workflowRegistry, + toolRegistry = module.toolRegistry, + send = sendFrame, ) val mapper = DomainEventMapper(module.artifactStore) @@ -201,6 +205,23 @@ class GlobalStreamHandler(private val module: ServerModule) { sessionSequence = 0L, ) session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started))) + + // Emit StageToolManifest for the newly created session (ARCH-TOOL-1) + val stages = graph.stages.map { (stageId, stageConfig) -> + val tools = stageConfig.allowedTools.mapNotNull { toolName -> + module.toolRegistry.resolve(toolName)?.let { tool -> + ToolDecl(name = tool.name, tier = tool.tier.level) + } + } + StageToolDecl(stageId = stageId.value, tools = tools) + } + session.send(Frame.Text(ProtocolSerializer.encodeServerMessage( + ServerMessage.StageToolManifest( + sessionId = sessionId, + workflowId = msg.workflowId, + stages = stages, + ), + ))) } } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/SessionEventBridgeTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/SessionEventBridgeTest.kt index e8cb5258..0b82568b 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/SessionEventBridgeTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/SessionEventBridgeTest.kt @@ -1,6 +1,7 @@ package com.correx.apps.server.bridge import com.correx.apps.server.protocol.ServerMessage +import com.correx.apps.server.registry.WorkflowRegistry import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalRepository @@ -21,6 +22,8 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.registry.ToolRegistry import com.correx.core.sessions.projections.replay.EventReplayer import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -98,6 +101,16 @@ class SessionEventBridgeTest { ), ) + private val noopWorkflowRegistry = object : WorkflowRegistry { + override fun listAll(): List = emptyList() + override fun find(workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? = null + } + + private val noopToolRegistry = object : ToolRegistry { + override fun resolve(name: String): Tool? = null + override fun all(): List = emptyList() + } + @Test fun `replaySnapshot sends mapped messages for all events`() = runTest { val events = listOf( @@ -112,6 +125,8 @@ class SessionEventBridgeTest { noopArtifactStore, orchRepo, approvalRepository, + noopWorkflowRegistry, + noopToolRegistry, ) { sent.add(it) } bridge.replaySnapshot() @@ -138,6 +153,8 @@ class SessionEventBridgeTest { noopArtifactStore, orchRepo, approvalRepository, + noopWorkflowRegistry, + noopToolRegistry, ) { sent.add(it) } bridge.replaySnapshot() @@ -162,6 +179,8 @@ class SessionEventBridgeTest { noopArtifactStore, activeOrchestrationRepository(), approvalRepository, + noopWorkflowRegistry, + noopToolRegistry, ) { sent.add(it) } bridge.streamLive(sessionId) @@ -181,7 +200,7 @@ class SessionEventBridgeTest { fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest { val store = fakeEventStore(allEventsList = emptyList()) val sent = mutableListOf() - val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) } + val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } bridge.replaySnapshot() assertEquals(1, sent.size) assertEquals(ServerMessage.SnapshotComplete, sent[0]) @@ -192,7 +211,7 @@ class SessionEventBridgeTest { val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L)) val store = fakeEventStore(allEventsList = events) val sent = mutableListOf() - val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) } + val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } bridge.replaySnapshot() assertEquals(ServerMessage.SnapshotComplete, sent.last()) } @@ -207,7 +226,7 @@ class SessionEventBridgeTest { override suspend fun lastGlobalSequence(): Long = 5L } val sent = mutableListOf() - val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) } + val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } bridge.replaySnapshot() val snapshot = sent[0] as ServerMessage.SessionSnapshot assertEquals(5L, snapshot.lastSequence) @@ -221,7 +240,7 @@ class SessionEventBridgeTest { ) val store = fakeEventStore(allEventsList = events) val sent = mutableListOf() - val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) } + val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) } bridge.replaySnapshot() val snapshot = sent[0] as ServerMessage.SessionSnapshot assertEquals(3L, snapshot.lastSessionSequence) @@ -240,6 +259,8 @@ class SessionEventBridgeTest { noopArtifactStore, activeOrchestrationRepository(), approvalRepository, + noopWorkflowRegistry, + noopToolRegistry, ) { sent.add(it) } channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L)) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt index 3ee61deb..75d2b6ea 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt @@ -3,6 +3,7 @@ package com.correx.apps.server.ws import com.correx.apps.server.bridge.DomainEventMapper import com.correx.apps.server.bridge.SessionEventBridge import com.correx.apps.server.protocol.ServerMessage +import com.correx.apps.server.registry.WorkflowRegistry import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalRepository @@ -21,6 +22,8 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.sessions.projections.replay.DefaultEventReplayer +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.registry.ToolRegistry import com.correx.core.sessions.projections.replay.EventReplayer import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancelAndJoin @@ -103,6 +106,16 @@ class GlobalStreamHandlerTest { }, ) + private val noopWorkflowRegistry = object : WorkflowRegistry { + override fun listAll(): List = emptyList() + override fun find(workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? = null + } + + private val noopToolRegistry = object : ToolRegistry { + override fun resolve(name: String): Tool? = null + override fun all(): List = emptyList() + } + private fun fakeApprovalRepository(store: EventStore): DefaultApprovalRepository = DefaultApprovalRepository( DefaultEventReplayer( @@ -121,7 +134,7 @@ class GlobalStreamHandlerTest { ): Pair> { val received = Channel(Channel.UNLIMITED) val approvals = fakeApprovalRepository(store) - val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, approvals) { received.send(it) } + val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, approvals, noopWorkflowRegistry, noopToolRegistry) { received.send(it) } return bridge to received } @@ -168,7 +181,7 @@ class GlobalStreamHandlerTest { val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L) val approvals = fakeApprovalRepository(store) val received = Channel(Channel.UNLIMITED) - val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { + val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { received.send(it) } val mapper = DomainEventMapper(noopArtifactStore) @@ -211,7 +224,7 @@ class GlobalStreamHandlerTest { val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L) val approvals = fakeApprovalRepository(store) val received = Channel(Channel.UNLIMITED) - val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { + val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { received.send(it) } val mapper = DomainEventMapper(noopArtifactStore) @@ -260,7 +273,7 @@ class GlobalStreamHandlerTest { } } val approvals = fakeApprovalRepository(store) - val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { } + val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { } val mapper = DomainEventMapper(noopArtifactStore) val job = launch { @@ -278,7 +291,7 @@ class GlobalStreamHandlerTest { val liveFlow = MutableSharedFlow() val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L) val approvals = fakeApprovalRepository(store) - val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { } + val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { } val mapper = DomainEventMapper(noopArtifactStore) val job = launch { 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 0ce828c0..8e856c3b 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 @@ -6,15 +6,15 @@ sealed class KeyEvent { object Cancel : KeyEvent() object Approve : KeyEvent() object Reject : KeyEvent() - object Steer : KeyEvent() object NavUp : KeyEvent() object NavDown : KeyEvent() object Filter : KeyEvent() object Tab : KeyEvent() - object ToggleApprovalOverlay : KeyEvent() - object ToggleEventOverlay : KeyEvent() object Enter : KeyEvent() object Backspace : KeyEvent() object Escape : KeyEvent() + object ToggleEventStrip : KeyEvent() + object ReturnToSessionList : KeyEvent() + object ShowPendingApproval : KeyEvent() data class CharInput(val ch: Char) : KeyEvent() } 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 2d8c1776..1338c686 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,18 +1,21 @@ package com.correx.apps.tui -import com.correx.apps.tui.components.activeSessionWidget +import com.correx.apps.tui.components.approvalSurfaceWidget +import com.correx.apps.tui.components.eventHistoryStripWidget import com.correx.apps.tui.components.inputBarWidget import com.correx.apps.tui.components.routerPanelWidget import com.correx.apps.tui.components.sessionListWidget import com.correx.apps.tui.components.statusBarWidget -import com.correx.apps.tui.components.toolsPanelWidget import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.KeyResolver import com.correx.apps.tui.input.mapKey import com.correx.apps.tui.reducer.Effect import com.correx.apps.tui.reducer.EffectDispatcher import com.correx.apps.tui.reducer.RootReducer +import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.TuiState +import com.correx.apps.tui.state.displayState +import com.correx.apps.tui.state.selectedPendingApproval import com.correx.apps.tui.ws.ConnectionEvent import com.correx.apps.tui.ws.TuiWsClient import dev.tamboui.layout.Constraint @@ -31,7 +34,7 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent private const val DEFAULT_PORT = 8080 private const val STATUS_HEIGHT = 1 -private const val TOP_ROW_HEIGHT = 9 +private const val EVENT_STRIP_HEIGHT = 4 private const val INPUT_HEIGHT = 4 private val log = LoggerFactory.getLogger("com.correx.apps.tui.main") @@ -90,7 +93,10 @@ fun main(args: Array) { val handler = EventHandler { event, _ -> if (event is TambouiKeyEvent) { mapKey(event) - ?.let { KeyResolver.resolve(it, state.inputMode, state.inputBuffer) } + ?.let { key -> + val hasPendingApproval = state.selectedPendingApproval() != null + KeyResolver.resolve(key, state.displayState, state.inputMode, hasPendingApproval, state.inputBuffer) + } ?.let(::dispatch) } true @@ -110,29 +116,72 @@ internal suspend fun dispatchAll(dispatcher: EffectDispatcher, effects: List renderIdleLayout(frame, state) + DisplayState.IN_SESSION -> renderInSessionLayout(frame, state) + DisplayState.APPROVAL -> renderApprovalLayout(frame, state) + } +} +private fun renderIdleLayout(frame: Frame, state: TuiState) { + val area = frame.area() val vertRects = Layout.vertical() .constraints( Constraint.length(STATUS_HEIGHT), - Constraint.length(TOP_ROW_HEIGHT), Constraint.fill(), Constraint.length(INPUT_HEIGHT), ) .split(area) - val topRects = Layout.horizontal() - .constraints( - Constraint.percentage(30), - Constraint.percentage(30), - Constraint.percentage(40), - ) - .split(vertRects[1]) - frame.renderWidget(statusBarWidget(state), vertRects[0]) - frame.renderWidget(sessionListWidget(state), topRects[0]) - frame.renderWidget(activeSessionWidget(state), topRects[1]) - frame.renderWidget(toolsPanelWidget(state), topRects[2]) - frame.renderWidget(routerPanelWidget(state), vertRects[2]) - frame.renderWidget(inputBarWidget(state), vertRects[3]) + frame.renderWidget(sessionListWidget(state), vertRects[1]) + frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[2]) +} + +private fun renderInSessionLayout(frame: Frame, state: TuiState) { + val area = frame.area() + val constraints = mutableListOf( + Constraint.length(STATUS_HEIGHT), + ) + if (state.eventStripVisible) { + constraints.add(Constraint.length(EVENT_STRIP_HEIGHT)) + } + constraints.add(Constraint.fill()) + constraints.add(Constraint.length(INPUT_HEIGHT)) + + val vertRects = Layout.vertical() + .constraints(constraints) + .split(area) + + var rectIdx = 0 + frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++]) + if (state.eventStripVisible) { + frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++]) + } + frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++]) + frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx]) +} + +private fun renderApprovalLayout(frame: Frame, state: TuiState) { + val area = frame.area() + val constraints = mutableListOf( + Constraint.length(STATUS_HEIGHT), + ) + if (state.eventStripVisible) { + constraints.add(Constraint.length(EVENT_STRIP_HEIGHT)) + } + constraints.add(Constraint.fill()) + constraints.add(Constraint.length(INPUT_HEIGHT)) + + val vertRects = Layout.vertical() + .constraints(constraints) + .split(area) + + var rectIdx = 0 + frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++]) + if (state.eventStripVisible) { + frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++]) + } + frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++]) + frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), vertRects[rectIdx]) } 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 deleted file mode 100644 index 9a723dcb..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.tui.state.SessionSummary -import com.correx.apps.tui.state.TuiState -import dev.tamboui.style.Style -import dev.tamboui.text.Line -import dev.tamboui.text.Span -import dev.tamboui.text.Text -import dev.tamboui.widgets.block.Block -import dev.tamboui.widgets.block.BorderType -import dev.tamboui.widgets.block.Borders -import dev.tamboui.widgets.paragraph.Paragraph - -private const val LABEL_WIDTH = 8 - -fun activeSessionWidget(state: TuiState): Paragraph { - val dimStyle = Style.create().dim().gray() - val session = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } - - val lines = if (session == null) { - listOf(Line.from(Span.styled("no session selected", dimStyle))) - } else { - buildList { - val stageVal = session.currentStageId ?: session.currentStage ?: "—" - add(labelRow("stage", Span.raw(stageVal))) - add(labelRow("status", statusSpanFor(session))) - add(labelRow("model", Span.styled(state.currentModel ?: "—", Style.create().cyan()))) - session.nextStageId?.let { add(labelRow("next", Span.styled(it, dimStyle))) } - } - } - - val block = Block.builder() - .title("active session") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - - return Paragraph.builder().text(Text.from(lines)).block(block).build() -} - -private fun labelRow(label: String, value: Span): Line = - Line.from(Span.raw(label.padEnd(LABEL_WIDTH)), value) - -private fun statusSpanFor(s: SessionSummary): Span { - val label = s.status.uppercase() - return when (s.status.lowercase()) { - "active", "running" -> Span.styled(label, Style.create().green()) - "paused" -> Span.styled(label, Style.create().yellow()) - "failed", "error" -> Span.styled(label, Style.create().red()) - "completed", "done" -> Span.styled(label, Style.create().dim().gray()) - else -> Span.raw(label) - } -} 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 deleted file mode 100644 index 2b3533b0..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.tui.state.ApprovalInfo -import dev.tamboui.style.Color -import dev.tamboui.style.Style -import dev.tamboui.text.Line -import dev.tamboui.text.Span -import dev.tamboui.text.Text -import dev.tamboui.widgets.block.Block -import dev.tamboui.widgets.block.BorderType -import dev.tamboui.widgets.block.Borders -import dev.tamboui.widgets.paragraph.Paragraph - -fun approvalPanelWidget(approval: ApprovalInfo): Paragraph { - val dimStyle = Style.create().dim().gray() - val lines = buildList { - add(Line.from(Span.styled("⚠ APPROVAL REQUIRED — Tier ${approval.tier}", Style.create().yellow().bold()))) - approval.toolName?.let { add(Line.from(Span.raw("tool: $it"))) } - if (approval.riskSummary.isNotEmpty()) { - add(Line.from(Span.raw("risk: ${approval.riskSummary}"))) - } - approval.preview?.let { add(Line.from(Span.raw("preview: $it"))) } - add(Line.from(Span.styled("[A] approve [R] reject [S] steer [D] details", dimStyle))) - } - val block = Block.builder() - .title("approval prompt") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .borderColor(Color.YELLOW) - .build() - return Paragraph.builder().text(Text.from(lines)).block(block).build() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt new file mode 100644 index 00000000..1096e974 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt @@ -0,0 +1,84 @@ +package com.correx.apps.tui.components + +import com.correx.apps.server.protocol.ApprovalDecision +import com.correx.apps.tui.state.TuiState +import com.correx.apps.tui.state.selectedPendingApproval +import dev.tamboui.style.Color +import dev.tamboui.style.Style +import dev.tamboui.text.Line +import dev.tamboui.text.Span +import dev.tamboui.text.Text +import dev.tamboui.widgets.block.Block +import dev.tamboui.widgets.block.BorderType +import dev.tamboui.widgets.block.Borders +import dev.tamboui.widgets.block.Title +import dev.tamboui.widgets.paragraph.Paragraph + +private const val PREVIEW_MAX_LINES = 20 + +fun approvalSurfaceWidget(state: TuiState): Paragraph { + val dimStyle = Style.create().dim().gray() + val approval = state.selectedPendingApproval() + val pendingDecision = state.pendingDecision + + val lines = buildList { + if (approval == null) { + add(Line.from(Span.styled("no pending approval", dimStyle))) + } else { + // Tool name, tier badge, risk summary + val toolName = approval.toolName ?: "(unknown tool)" + val toolSpan = Span.styled(toolName, Style.create()) + val tierBadge = Span.styled("T${approval.tier}", Style.create().yellow()) + val riskSpan = Span.styled(approval.riskSummary, dimStyle) + add(Line.from(toolSpan, Span.raw(" "), tierBadge, Span.raw(" "), riskSpan)) + + // Command preview (monospace, max 20 lines, truncation notice) + if (approval.preview != null) { + val previewLines = approval.preview.lines() + if (previewLines.isNotEmpty()) { + val visibleLines = previewLines.take(PREVIEW_MAX_LINES) + for (line in visibleLines) { + add(Line.from(Span.raw(" $line"))) + } + if (previewLines.size > PREVIEW_MAX_LINES) { + val remaining = previewLines.size - PREVIEW_MAX_LINES + add(Line.from(Span.styled(" (preview truncated, $remaining more lines)", dimStyle))) + } + } else { + add(Line.from(Span.styled(" no preview available", dimStyle))) + } + } else { + add(Line.from(Span.styled(" no preview available", dimStyle))) + } + + // Steering note input indicator + add(Line.from(Span.styled(" steering note: ", dimStyle))) + + // Pending decision indicator (color coded) + val decisionSpan = when (pendingDecision) { + null -> Span.styled(" decision: \u2014", dimStyle) + ApprovalDecision.APPROVE -> Span.styled(" decision: APPROVE", Style.create().green()) + ApprovalDecision.REJECT -> Span.styled(" decision: REJECT", Style.create().red()) + } + add(Line.from(decisionSpan)) + + // Approve/reject key hints + add(Line.from(Span.styled( + " ctrl+a approve \u00B7 ctrl+r reject \u00B7 enter submit \u00B7 esc dismiss", + dimStyle + ))) + } + } + + val block = Block.builder() + .title(Title.from(Span.styled("approval", Style.create().yellow())).centered()) + .borders(Borders.ALL) + .borderType(BorderType.ROUNDED) + .borderColor(Color.YELLOW) + .build() + + return Paragraph.builder() + .text(Text.from(lines)) + .block(block) + .build() +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt new file mode 100644 index 00000000..63aab240 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt @@ -0,0 +1,86 @@ +package com.correx.apps.tui.components + +import com.correx.apps.tui.state.ToolDisplayStatus +import com.correx.apps.tui.state.TuiState +import dev.tamboui.style.Style +import dev.tamboui.text.Line +import dev.tamboui.text.Span +import dev.tamboui.text.Text +import dev.tamboui.widgets.paragraph.Paragraph + +private const val DETAIL_MAX = 60 +private const val TYPE_WIDTH = 10 + +fun eventHistoryStripWidget(state: TuiState): Paragraph { + val dimStyle = Style.create().dim().gray() + val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } + + val lines = buildList { + if (selectedSession == null) { + add(Line.from(Span.styled("no session selected", dimStyle))) + } else { + // Stage ID and status line (compact, dim metadata) + val stageInfo = buildString { + if (selectedSession.currentStageId != null) { + append(selectedSession.currentStageId) + } + if (selectedSession.currentStage != null) { + if (isNotEmpty()) append(" · ") + append(selectedSession.currentStage) + } + if (isEmpty()) append("(no stage)") + append(" ") + append(selectedSession.status) + } + add(Line.from(Span.styled(stageInfo, dimStyle))) + + // Recent events (plain text, compact format) + val events = selectedSession.recentEvents + if (events.isEmpty()) { + add(Line.from(Span.styled("no events", dimStyle))) + } else { + for (ev in events) { + val typePadded = ev.type.padEnd(TYPE_WIDTH, ' ') + val detail = if (ev.detail.length > DETAIL_MAX) { + ev.detail.take(DETAIL_MAX - 3) + "..." + } else { + ev.detail + } + val row = "[${ev.timestamp}] [$typePadded] $detail" + add(Line.from(Span.raw(row))) + } + } + + // Tool manifest for current stage (from toolsByStage) + val stageId = selectedSession.currentStageId + if (stageId != null) { + val tools = selectedSession.toolsByStage[stageId] + if (!tools.isNullOrEmpty()) { + add(Line.from(Span.styled("declared tools:", dimStyle))) + for (tool in tools) { + add(Line.from(Span.raw(" ${tool.name} T${tool.tier}"))) + } + } + } + + // Runtime tool records (TuiToolRecord from SessionSummary.tools) + if (selectedSession.tools.isNotEmpty()) { + add(Line.from(Span.styled("tool records:", dimStyle))) + for (tool in selectedSession.tools) { + val statusSymbol = when (tool.status) { + ToolDisplayStatus.REQUESTED -> "?" + ToolDisplayStatus.STARTED -> "\u25B6" + ToolDisplayStatus.COMPLETED -> "\u2713" + ToolDisplayStatus.FAILED -> "\u2717" + ToolDisplayStatus.REJECTED -> "\u2298" + } + add(Line.from(Span.raw(" $statusSymbol ${tool.name} T${tool.tier}"))) + } + } + } + } + + return Paragraph.builder() + .text(Text.from(lines)) + .build() +} 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 d7c05eb4..e2ef38b2 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,5 +1,7 @@ package com.correx.apps.tui.components +import com.correx.apps.server.protocol.ApprovalDecision +import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.TuiState import dev.tamboui.style.Style @@ -11,10 +13,8 @@ import dev.tamboui.widgets.block.BorderType import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.paragraph.Paragraph -fun inputBarWidget(state: TuiState): Paragraph { +fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph { val dimStyle = Style.create().dim().gray() - val hasApproval = state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval != null - val hasSession = state.sessions.selectedId != null val sessionName = state.sessions.sessions .firstOrNull { it.id == state.sessions.selectedId } @@ -22,12 +22,21 @@ fun inputBarWidget(state: TuiState): Paragraph { ?: state.sessions.selectedId?.take(6) ?: "(no session)" - val (row1, row2) = when (state.inputMode) { - InputMode.ROUTER -> createRowsForRouter(state, dimStyle, sessionName, hasApproval, hasSession) - InputMode.NAVIGATE -> createRowsForNavigate(dimStyle, sessionName, hasApproval, hasSession) - InputMode.FILTER -> createRowsForFilter(state, dimStyle) - InputMode.STEER -> createRowsForSteer(state, dimStyle, sessionName) + val (row1, row2) = when (displayState) { + DisplayState.IDLE -> when (state.inputMode) { + InputMode.ROUTER -> createRowsForIdleRouter(state, dimStyle, sessionName) + InputMode.FILTER -> createRowsForFilter(state, dimStyle) + } + DisplayState.IN_SESSION -> when (state.inputMode) { + InputMode.ROUTER -> createRowsForInSessionRouter(state, dimStyle, sessionName) + InputMode.FILTER -> createRowsForFilter(state, dimStyle) + } + + DisplayState.APPROVAL -> when (state.inputMode) { + InputMode.ROUTER -> createRowsForApprovalRouter(state, dimStyle) + InputMode.FILTER -> createRowsForFilter(state, dimStyle) + } } val block = Block.builder() @@ -39,7 +48,7 @@ fun inputBarWidget(state: TuiState): Paragraph { return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build() } -private fun createRowsForSteer( +private fun createRowsForIdleRouter( state: TuiState, dimStyle: Style?, sessionName: String, @@ -47,9 +56,50 @@ private fun createRowsForSteer( val cursor = if (state.inputBuffer.isNotEmpty()) { Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer)) } else { - Line.from(Span.raw("▌ "), Span.styled("Steer the session…", dimStyle)) + Line.from(Span.raw("▌ "), Span.styled("Session name…", dimStyle)) } - return cursor to Line.from(Span.styled("$sessionName · steering esc cancel enter send", dimStyle)) + return cursor to Line.from(Span.styled( + "$sessionName · router tab filter ctrl+n new ctrl+e events ctrl+q", + dimStyle, + )) +} + +private fun createRowsForInSessionRouter( + state: TuiState, + dimStyle: Style?, + sessionName: String, +): Pair { + val cursor = if (state.inputBuffer.isNotEmpty()) { + Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer)) + } else { + Line.from(Span.raw("▌ "), Span.styled("Ask anything…", dimStyle)) + } + return cursor to Line.from(Span.styled( + "$sessionName · router tab filter ↑↓ history ctrl+e events ctrl+l back ctrl+q", + dimStyle, + )) +} + +private fun createRowsForApprovalRouter( + state: TuiState, + dimStyle: Style?, +): Pair { + val cursor = if (state.inputBuffer.isNotEmpty()) { + Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer)) + } else { + Line.from(Span.raw("▌ "), Span.styled("Steering note (optional)…", dimStyle)) + } + + val decisionIndicator = when (state.pendingDecision) { + null -> "" + ApprovalDecision.APPROVE -> " [decision: APPROVE]" + ApprovalDecision.REJECT -> " [decision: REJECT]" + } + + return cursor to Line.from(Span.styled( + "approval · ctrl+a approve ctrl+r reject ↵ enter submit esc dismiss$decisionIndicator", + dimStyle, + )) } private fun createRowsForFilter( @@ -63,48 +113,3 @@ private fun createRowsForFilter( } return cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle)) } - -private fun createRowsForNavigate( - dimStyle: Style?, - sessionName: String, - hasApproval: Boolean, - hasSession: Boolean, -): Pair { - val row1Line = Line.from(Span.styled(" ↑↓ sessions enter select", dimStyle)) - val hints = buildList { - add("$sessionName · navigate") - add("tab router") - if (hasApproval) { - add("ctrl+a approve"); add("ctrl+r reject") - } - if (hasSession) add("ctrl+s steer") - add("ctrl+e events") - add("ctrl+q") - }.joinToString(" ") - return row1Line to Line.from(Span.styled(hints, dimStyle)) -} - -private fun createRowsForRouter( - state: TuiState, - dimStyle: Style?, - sessionName: String, - hasApproval: Boolean, - hasSession: Boolean, -): Pair { - val cursor = if (state.inputBuffer.isNotEmpty()) { - Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer)) - } else { - Line.from(Span.raw("▌ "), Span.styled("Ask anything…", dimStyle)) - } - val hints = buildList { - add("$sessionName · router") - add("tab navigate") - if (hasApproval) { - add("ctrl+a approve"); add("ctrl+r reject") - } - if (hasSession) add("ctrl+s steer") - add("ctrl+e events") - add("ctrl+q") - }.joinToString(" ") - return cursor to Line.from(Span.styled(hints, dimStyle)) -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt index f7bf7deb..95298f84 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt @@ -1,7 +1,6 @@ package com.correx.apps.tui.components import com.correx.apps.tui.state.TuiState -import com.correx.apps.tui.state.selectedPendingApproval import dev.tamboui.style.Style import dev.tamboui.text.Line import dev.tamboui.text.Span @@ -14,7 +13,7 @@ import dev.tamboui.widgets.paragraph.Paragraph fun routerPanelWidget(state: TuiState): Paragraph { val dimStyle = Style.create().dim().gray() - val mainLines = buildList { + val lines = buildList { if (!state.routerConnected) { add(Line.from(Span.styled("not connected — epic 14", dimStyle))) } else { @@ -24,44 +23,11 @@ fun routerPanelWidget(state: TuiState): Paragraph { } } - val activeApproval = state.selectedPendingApproval() - val overlayLines: List = when { - state.approvalOverlayVisible && activeApproval != null -> { - buildList { - add(Line.from(Span.styled("╭─ approval [alt+h to hide] ─────────────╮", Style.create().yellow()))) - val tierLine = "│ ⚠ ${activeApproval.tier} │ ${activeApproval.toolName ?: "no tool name"} │ ${ - activeApproval.riskSummary.take(12) - }... │" - add(Line.from(Span.styled(tierLine, Style.create().yellow()))) - val previewLine = "│ ${(activeApproval.preview ?: "no preview").take(40)}… │" - add(Line.from(Span.styled(previewLine, dimStyle))) - add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().yellow()))) - } - } - - state.eventOverlayVisible -> { - val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } - val events = selectedSession?.recentEvents ?: emptyList() - buildList { - add(Line.from(Span.styled("╭─ events [ctrl+e to hide] ───────────────╮", Style.create().magenta()))) - for (ev in events) { - val row = "│ ${ev.timestamp} ${ev.type} ${ev.detail} │" - add(Line.from(Span.styled(row, dimStyle))) - } - add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().magenta()))) - } - } - - else -> emptyList() - } - - val allLines = mainLines + overlayLines - val block = Block.builder() .title("router") .borders(Borders.ALL) .borderType(BorderType.ROUNDED) .build() - return Paragraph.builder().text(Text.from(allLines)).block(block).build() + return Paragraph.builder().text(Text.from(lines)).block(block).build() } 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 fb838aa7..9c4e48a4 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 @@ -1,7 +1,9 @@ package com.correx.apps.tui.components +import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.ProviderType import com.correx.apps.tui.state.TuiState +import com.correx.apps.tui.state.displayState import dev.tamboui.style.Style import dev.tamboui.text.Line import dev.tamboui.text.Span @@ -10,12 +12,12 @@ import dev.tamboui.widgets.paragraph.Paragraph fun statusBarWidget(state: TuiState): Paragraph { val dimStyle = Style.create().dim().gray() - val sep = Span.styled(" │ ", dimStyle) + val sep = Span.styled(" \u2502 ", dimStyle) val connSpan = if (state.connection.connected) { - Span.styled("●", Style.create().green()) + Span.styled("\u25CF", Style.create().green()) } else { - Span.styled("○", Style.create().dim().red()) + Span.styled("\u25CB", Style.create().dim().red()) } val connLabel = if (state.connection.connected) Span.raw(" connected") else Span.raw(" disconnected") @@ -27,6 +29,37 @@ fun statusBarWidget(state: TuiState): Paragraph { val sessionsSpan = Span.styled("${state.sessions.sessions.size} sessions", dimStyle) + // Background update badge (+N updated, dim yellow, only when displayState != IDLE) + val backgroundBadge = if (state.displayState != DisplayState.IDLE && state.sessions.backgroundUpdateCount > 0) { + Span.styled("+${state.sessions.backgroundUpdateCount} updated", Style.create().dim().yellow()) + } else { + null + } + + // Compact tool manifest for selected session's current stage + val toolManifestSpan = run { + val selected = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } + if (selected != null) { + val stageId = selected.currentStageId + if (stageId != null) { + val tools = selected.toolsByStage[stageId] + if (tools != null && tools.isNotEmpty()) { + val text = tools.joinToString(" \u00B7 ") { tool -> + val suffix = if (tool.tier >= 2) "\u26A0" else "" + "${tool.name} T${tool.tier}$suffix" + } + Span.styled(text, dimStyle) + } else { + Span.styled("tools: \u2014", dimStyle) + } + } else { + null + } + } else { + null + } + } + val spans = buildList { add(connSpan) add(connLabel) @@ -36,9 +69,17 @@ fun statusBarWidget(state: TuiState): Paragraph { add(modelSpan) add(sep) add(sessionsSpan) + if (backgroundBadge != null) { + add(sep) + add(backgroundBadge) + } + if (toolManifestSpan != null) { + add(sep) + add(toolManifestSpan) + } if (state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval != null) { add(sep) - add(Span.styled("[⚠ approval]", Style.create().yellow())) + add(Span.styled("[\u26A0 approval]", Style.create().yellow())) } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ToolsPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ToolsPanel.kt deleted file mode 100644 index 1566c87f..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ToolsPanel.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.tui.state.ToolDisplayStatus -import com.correx.apps.tui.state.TuiState -import dev.tamboui.style.Style -import dev.tamboui.text.Line -import dev.tamboui.text.Span -import dev.tamboui.text.Text -import dev.tamboui.widgets.block.Block -import dev.tamboui.widgets.block.BorderType -import dev.tamboui.widgets.block.Borders -import dev.tamboui.widgets.paragraph.Paragraph - -private const val ARGS_MAX = 30 - -fun toolsPanelWidget(state: TuiState): Paragraph { - val dimStyle = Style.create().dim().gray() - val amberStyle = Style.create().yellow() - val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } - val tools = selectedSession?.tools ?: emptyList() - - val lines = buildList { - for (tool in tools) { - val warn = tool.tier >= 2 || tool.status == ToolDisplayStatus.REQUESTED - val nameSpans = buildList { - add(Span.raw(tool.name)) - if (warn) { - add(Span.raw(" ")) - add(Span.styled("⚠", amberStyle)) - } - } - add(Line.from(nameSpans)) - tool.argsPreview?.let { args -> - val truncated = if (args.length > ARGS_MAX) args.take(ARGS_MAX - 1) + "…" else args - add(Line.from(Span.styled(" $truncated", dimStyle))) - } - } - } - - val block = Block.builder() - .title("tools") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - - return Paragraph.builder().text(Text.from(lines)).block(block).build() -} 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 index 90e1d8bd..5024f06b 100644 --- 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 @@ -8,7 +8,6 @@ sealed interface 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 @@ -16,13 +15,14 @@ sealed interface Action { data object Backspace : Action data object SubmitInput : Action data object CancelInput : Action + data object ToggleEventStrip : Action + data object SubmitApprovalDecision : Action + data object ReturnToSessionList : Action + data object ShowPendingApproval : 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 - data object ToggleApprovalOverlay : Action - data object ToggleEventOverlay : Action - data object EnterSteer : Action data object CycleMode : Action data object NoOp : 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 index 37466b5a..f3d50a04 100644 --- 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 @@ -1,56 +1,71 @@ package com.correx.apps.tui.input import com.correx.apps.tui.KeyEvent +import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.InputMode object KeyResolver { - fun resolve(key: KeyEvent, mode: InputMode, inputText: String): Action? { - val global = resolveGlobal(key) + fun resolve( + key: KeyEvent, + displayState: DisplayState, + inputMode: InputMode, + hasPendingApproval: Boolean, + inputText: String, + ): Action? { + // Global actions (I-NAV-2): work regardless of display state + val global = when (key) { + KeyEvent.Quit -> Action.Quit + KeyEvent.Approve -> Action.ApproveActive + KeyEvent.Reject -> Action.RejectActive + else -> null + } if (global != null) return global - return when (mode) { - InputMode.ROUTER -> resolveRouterMode(key, inputText) - InputMode.NAVIGATE -> resolveNavigateMode(key) - InputMode.FILTER, InputMode.STEER -> resolveTextMode(key, inputText) + + // FILTER mode override: ↑↓ always navigates sessions list + if (inputMode == InputMode.FILTER) { + return when (key) { + KeyEvent.NavUp -> Action.NavigateUp + KeyEvent.NavDown -> Action.NavigateDown + KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput + KeyEvent.Escape -> Action.CancelInput + KeyEvent.Tab -> Action.CycleMode + KeyEvent.Backspace -> Action.Backspace + is KeyEvent.CharInput -> Action.AppendChar(key.ch) + else -> null + } + } + + // Display-state-aware routing (ROUTER mode) + return when (displayState) { + DisplayState.IDLE -> when (key) { + KeyEvent.NavUp -> Action.NavigateUp + KeyEvent.NavDown -> Action.NavigateDown + KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput + KeyEvent.Tab -> Action.CycleMode + KeyEvent.Backspace -> Action.Backspace + is KeyEvent.CharInput -> Action.AppendChar(key.ch) + else -> null + } + + DisplayState.IN_SESSION -> when (key) { + KeyEvent.NavUp -> Action.NavigateUp + KeyEvent.NavDown -> Action.NavigateDown + KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput + KeyEvent.Tab -> Action.CycleMode + KeyEvent.Backspace -> Action.Backspace + is KeyEvent.CharInput -> Action.AppendChar(key.ch) + KeyEvent.ShowPendingApproval -> if (hasPendingApproval) Action.ShowPendingApproval else null + else -> null + } + + DisplayState.APPROVAL -> when (key) { + KeyEvent.Enter -> Action.SubmitApprovalDecision + KeyEvent.Escape -> Action.CancelInput + KeyEvent.Tab -> Action.CycleMode + KeyEvent.Backspace -> Action.Backspace + is KeyEvent.CharInput -> Action.AppendChar(key.ch) + else -> null + } } } - - private fun resolveGlobal(key: KeyEvent): Action? = when (key) { - KeyEvent.ToggleApprovalOverlay -> Action.ToggleApprovalOverlay - KeyEvent.ToggleEventOverlay -> Action.ToggleEventOverlay - else -> null - } - - private fun resolveRouterMode(key: KeyEvent, inputText: String): Action? = when (key) { - KeyEvent.Quit -> Action.Quit - KeyEvent.NewSession -> Action.OpenNewSessionPrompt - KeyEvent.Cancel -> Action.CancelSelectedSession - KeyEvent.Approve -> Action.ApproveActive - KeyEvent.Reject -> Action.RejectActive - KeyEvent.Steer -> Action.OpenSteeringPrompt - KeyEvent.NavUp -> Action.NavigateUp - KeyEvent.NavDown -> Action.NavigateDown - KeyEvent.Filter -> Action.CycleMode - KeyEvent.Backspace -> Action.Backspace - KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput - is KeyEvent.CharInput -> Action.AppendChar(key.ch) - else -> null - } - - private fun resolveNavigateMode(key: KeyEvent): Action? = when (key) { - KeyEvent.NavUp -> Action.NavigateUp - KeyEvent.NavDown -> Action.NavigateDown - KeyEvent.Enter -> Action.SubmitInput - KeyEvent.Filter -> Action.CycleMode - KeyEvent.Escape -> Action.CancelInput - else -> null - } - - private fun resolveTextMode(key: KeyEvent, inputText: String): Action? = when (key) { - KeyEvent.Filter -> Action.CycleMode - KeyEvent.Escape -> Action.CancelInput - KeyEvent.Backspace -> Action.Backspace - KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput - is KeyEvent.CharInput -> Action.AppendChar(key.ch) - else -> null - } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt index c6750d58..c2e9f3df 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt @@ -10,15 +10,8 @@ private val log = LoggerFactory.getLogger("com.correx.apps.tui.input") @Suppress("CyclomaticComplexMethod", "ReturnCount") fun mapKey(event: TambouiKeyEvent): KeyEvent? { log.debug("got event: {}", event) - if (event.isCtrlC) return KeyEvent.Quit if (event.hasAlt()) { - return when { - event.isChar('h') -> KeyEvent.ToggleApprovalOverlay.also { - log.debug("got event for toggle approval") - } - - else -> null - } + return null } if (event.hasCtrl()) { return when { @@ -27,10 +20,9 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? { event.isChar('c') -> KeyEvent.Cancel event.isChar('a') -> KeyEvent.Approve event.isChar('r') -> KeyEvent.Reject - event.isChar('s') -> KeyEvent.Steer - - - event.isChar('e') -> KeyEvent.ToggleEventOverlay + event.isChar('l') -> KeyEvent.ReturnToSessionList + event.isChar('e') -> KeyEvent.ToggleEventStrip + event.isChar('h') -> KeyEvent.ShowPendingApproval else -> null } } @@ -40,7 +32,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? { KeyCode.ENTER -> KeyEvent.Enter KeyCode.ESCAPE -> KeyEvent.Escape KeyCode.BACKSPACE -> KeyEvent.Backspace - KeyCode.TAB -> KeyEvent.Filter + KeyCode.TAB -> KeyEvent.Tab KeyCode.CHAR -> { val ch = event.character() if (ch.isISOControl()) null else KeyEvent.CharInput(ch) 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 index f8116a15..25e1fba1 100644 --- 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 @@ -16,75 +16,75 @@ object InputReducer { ): Pair> = when (action) { is Action.CycleMode -> state.copy( inputMode = when (state.inputMode) { - InputMode.ROUTER -> InputMode.NAVIGATE - InputMode.NAVIGATE -> InputMode.FILTER + InputMode.ROUTER -> InputMode.FILTER InputMode.FILTER -> InputMode.ROUTER - InputMode.STEER -> InputMode.ROUTER }, + ) to emptyList() + + is Action.OpenNewSessionPrompt -> state.copy( + inputMode = InputMode.ROUTER, inputBuffer = "", ) to emptyList() - is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() - is Action.OpenSteeringPrompt -> { + is Action.AppendChar -> state.copy( + inputBuffer = state.inputBuffer + action.ch, + ) to emptyList() + + is Action.Backspace -> state.copy( + inputBuffer = state.inputBuffer.dropLast(1), + ) to emptyList() + + is Action.CancelInput -> state.copy( + inputMode = InputMode.ROUTER, + inputBuffer = "", + ) to emptyList() + + is Action.SubmitInput -> state.copy( + inputBuffer = "", + ) to emptyList() + + is Action.SubmitApprovalDecision -> { + val decision = state.pendingDecision ?: ApprovalDecision.REJECT val active = state.selectedPendingApproval() + val steeringNote = state.inputBuffer.ifBlank { null } if (active != null) { - state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() - } else { - state to emptyList() - } - } - - is Action.EnterSteer -> state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() - is Action.OpenFilter -> state.copy(inputMode = InputMode.FILTER, inputBuffer = "") to emptyList() - is Action.AppendChar -> state.copy(inputBuffer = state.inputBuffer + action.ch) to emptyList() - is Action.Backspace -> state.copy(inputBuffer = state.inputBuffer.dropLast(1)) to emptyList() - is Action.CancelInput -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() - is Action.SubmitInput -> when (state.inputMode) { - InputMode.ROUTER -> { - val text = state.inputBuffer.trim() - if (text.isNotBlank()) { - state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to listOf( - Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)), - ) - } else { - state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() - } - } - - InputMode.STEER -> { - val text = state.inputBuffer.trim() - val active = state.selectedPendingApproval() - if (active != null && text.isNotBlank()) { - val selectedId = state.sessions.selectedId - val clearedSessions = state.sessions.copy( - sessions = state.sessions.sessions.map { s -> - if (s.id == selectedId) s.copy(pendingApproval = null) else s - }, - ) - state.copy( - inputMode = InputMode.ROUTER, - inputBuffer = "", - sessions = clearedSessions, - ) to listOf( - Effect.SendWs( - ClientMessage.ApprovalResponse( - requestId = ApprovalRequestId(active.requestId), - // TODO: hardcoded APPROVE until the approve/reject overlay UX lands; - // then the user picks the decision explicitly alongside the note. - decision = ApprovalDecision.APPROVE, - steeringNote = text, - ), + val selectedId = state.sessions.selectedId + val clearedSessions = state.sessions.copy( + sessions = state.sessions.sessions.map { s -> + if (s.id == selectedId) s.copy(pendingApproval = null) else s + }, + ) + state.copy( + pendingDecision = null, + approvalDismissed = false, + inputBuffer = "", + sessions = clearedSessions, + ) to listOf( + Effect.SendWs( + ClientMessage.ApprovalResponse( + requestId = ApprovalRequestId(active.requestId), + decision = decision, + steeringNote = steeringNote, ), - ) - } else { - state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() - } + ), + ) + } else { + state.copy( + pendingDecision = null, + approvalDismissed = false, + inputBuffer = "", + ) to emptyList() } - - InputMode.FILTER -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() - InputMode.NAVIGATE -> state to emptyList() } + is Action.ShowPendingApproval -> state.copy( + approvalDismissed = false, + ) to emptyList() + + is Action.ReturnToSessionList -> state.copy( + sessions = state.sessions.copy(selectedId = null), + ) to emptyList() + else -> state 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 index 7fe4d37d..5e69903b 100644 --- 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 @@ -1,8 +1,12 @@ package com.correx.apps.tui.reducer +import com.correx.apps.server.protocol.ApprovalDecision import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.tui.input.Action +import com.correx.apps.tui.state.DisplayState +import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.TuiState +import com.correx.apps.tui.state.displayState import org.slf4j.LoggerFactory private val log = LoggerFactory.getLogger(RootReducer::class.java.name) @@ -36,6 +40,7 @@ object RootReducer { return current to allEffects } + @Suppress("CyclomaticComplexMethod") private fun reduceThroughSubReducers( state: TuiState, action: Action, @@ -45,41 +50,150 @@ object RootReducer { // Effect.Quit appended LAST so all cleanup effects from sub-reducers dispatch first. // See effects-01 (sequential dispatch) for why list position is a runtime guarantee. // I-Q3: sub-reducers must not synthesize Effect.Quit — it is a terminal signal owned by RootReducer. + + // Capture pre-reducer state for cross-reducer weaving. val prevInputMode = state.inputMode val prevInputBuffer = state.inputBuffer + val prevSelectedId = state.sessions.selectedId log.debug("input state before reducers={}", state.input) val (afterInput, ie) = InputReducer.reduce(state, action) + log.debug("sessions state before reducers={}", state.sessions) - val (sessions, se) = SessionsReducer.reduce(afterInput.sessions, prevInputMode, prevInputBuffer, action, clock) + val (sessions, se) = SessionsReducer.reduce( + sessions = afterInput.sessions, + displayState = afterInput.displayState, + inputMode = prevInputMode, + inputText = prevInputBuffer, + action = action, + clock = clock, + ) log.debug("connection state before reducers={}", state.connection) val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action) log.debug("provider state before reducers={}", state.provider) val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action) - val selectedPending = sessions.sessions.firstOrNull { it.id == sessions.selectedId }?.pendingApproval - val prevSelectedPending = afterInput.sessions.sessions.firstOrNull { it.id == afterInput.sessions.selectedId }?.pendingApproval - val approvalJustArrived = selectedPending != null && prevSelectedPending == null + // ── Cross-reducer state weaving ── + // After all sub-reducers have run, weave fields that span multiple reducer boundaries. + // InputReducer runs first (clears inputBuffer, mode), then SessionsReducer (applies filter, + // sends effects), then ConnectionReducer and ProviderReducer. + // 1. Base merge: overlay sub-reducer outputs onto state after InputReducer. val withSubReducers = afterInput.copy( sessions = sessions, connection = connection, provider = provider, - approvalOverlayVisible = if (approvalJustArrived) true else afterInput.approvalOverlayVisible, ) - val final = when (action) { - is Action.ToggleApprovalOverlay -> withSubReducers.copy( - approvalOverlayVisible = !withSubReducers.approvalOverlayVisible, - eventOverlayVisible = false, - ) - is Action.ToggleEventOverlay -> withSubReducers.copy( - eventOverlayVisible = !withSubReducers.eventOverlayVisible, - approvalOverlayVisible = false, - ) - - else -> withSubReducers + // 2. backgroundUpdateCount reset: on any selectedId change (ARCH-BADGE-2). + val withBgReset = if (sessions.selectedId != prevSelectedId) { + withSubReducers.copy(sessions = sessions.copy(backgroundUpdateCount = 0)) + } else { + withSubReducers } + + // 3. Action-specific cross-field handles. + val final = when (action) { + is Action.ApproveActive -> withBgReset.copy( + pendingDecision = ApprovalDecision.APPROVE, + ) + + is Action.RejectActive -> withBgReset.copy( + pendingDecision = ApprovalDecision.REJECT, + ) + + is Action.CancelInput -> when { + prevInputMode == InputMode.FILTER -> withBgReset + else -> withBgReset.copy(approvalDismissed = true) + } + + is Action.ToggleEventStrip -> { + if (withBgReset.displayState == DisplayState.IDLE) withBgReset + else withBgReset.copy(eventStripVisible = !withBgReset.eventStripVisible) + } + + // Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1). + is Action.SubmitInput -> { + val text = prevInputBuffer.trim() + if (text.isNotBlank() && sessions.selectedId != null && + prevInputMode == InputMode.ROUTER && + withBgReset.displayState == com.correx.apps.tui.state.DisplayState.IN_SESSION + ) { + val selectedId = sessions.selectedId!! + val currentHistory = withBgReset.inputHistory[selectedId] ?: emptyList() + val updatedHistory = (currentHistory + text).takeLast(50) + withBgReset.copy( + inputHistory = withBgReset.inputHistory + (selectedId to updatedHistory), + ) + } else { + withBgReset + } + } + + // Input history navigation on NavigateUp in IN_SESSION + ROUTER (ARCH-HISTORY-2/3). + is Action.NavigateUp -> { + if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) { + val selectedId = withBgReset.sessions.selectedId + if (selectedId != null) { + val history = withBgReset.inputHistory[selectedId] ?: emptyList() + if (history.isNotEmpty()) { + when (withBgReset.inputHistoryIndex) { + -1 -> withBgReset.copy( + savedInputBuffer = withBgReset.inputBuffer, + inputHistoryIndex = history.size - 1, + inputBuffer = history[history.size - 1], + ) + 0 -> withBgReset // at oldest entry, stay + else -> { + val newIndex = withBgReset.inputHistoryIndex - 1 + withBgReset.copy( + inputHistoryIndex = newIndex, + inputBuffer = history[newIndex], + ) + } + } + } else { + withBgReset + } + } else { + withBgReset + } + } else { + withBgReset + } + } + + // Input history navigation on NavigateDown in IN_SESSION + ROUTER (ARCH-HISTORY-2/3). + is Action.NavigateDown -> { + if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) { + val selectedId = withBgReset.sessions.selectedId + if (selectedId != null) { + val history = withBgReset.inputHistory[selectedId] ?: emptyList() + when (withBgReset.inputHistoryIndex) { + -1 -> withBgReset // already at current buffer + history.size - 1 -> withBgReset.copy( + inputHistoryIndex = -1, + inputBuffer = withBgReset.savedInputBuffer, + ) + else -> { + val newIndex = withBgReset.inputHistoryIndex + 1 + withBgReset.copy( + inputHistoryIndex = newIndex, + inputBuffer = history[newIndex], + ) + } + } + } else { + withBgReset + } + } else { + withBgReset + } + } + + else -> withBgReset + } + val combined = ie + se + ce + pe val withTerminal = if (action is Action.Quit) combined + Effect.Quit else combined return final to withTerminal 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 index 28a62e84..d1b23c0f 100644 --- 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 @@ -1,15 +1,16 @@ 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.PauseReason 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.DisplayState import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.ToolDisplayStatus +import com.correx.apps.tui.state.ToolManifestEntry import com.correx.apps.tui.state.TuiEventEntry import com.correx.apps.tui.state.TuiToolRecord import com.correx.core.events.types.ApprovalRequestId @@ -26,27 +27,44 @@ object SessionsReducer { fun reduce( sessions: SessionsState, + displayState: DisplayState, inputMode: InputMode, inputText: String, action: Action, clock: () -> Long = System::currentTimeMillis, ): Pair> = when (action) { - is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) { - navigateUp(sessions) to emptyList() - } else { - sessions to emptyList() + is Action.NavigateUp -> when { + inputMode == InputMode.FILTER -> navigateUp(sessions) to emptyList() + displayState == DisplayState.IDLE -> navigateUp(sessions) to emptyList() + displayState == DisplayState.IN_SESSION -> sessions to emptyList() + else -> sessions to emptyList() } - is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) { - navigateDown(sessions) to emptyList() - } else { - sessions to emptyList() + is Action.NavigateDown -> when { + inputMode == InputMode.FILTER -> navigateDown(sessions) to emptyList() + displayState == DisplayState.IDLE -> navigateDown(sessions) to emptyList() + displayState == DisplayState.IN_SESSION -> sessions to emptyList() + else -> sessions to emptyList() } - is Action.SubmitInput -> if (inputMode == InputMode.FILTER) { - sessions.copy(filter = inputText) to emptyList() - } else { - sessions to emptyList() + is Action.SubmitInput -> when { + inputMode == InputMode.FILTER -> sessions.copy(filter = inputText) to emptyList() + displayState == DisplayState.IDLE -> { + val text = inputText.trim() + if (text.isNotBlank()) { + sessions to listOf( + Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)), + ) + } else { + sessions to emptyList() + } + } + displayState == DisplayState.IN_SESSION -> { + // Chat send is blocked until Epic 14 (router integration). + // History append is handled by RootReducer cross-field weave. + sessions to emptyList() + } + else -> sessions to emptyList() } is Action.CancelInput -> if (inputMode == InputMode.FILTER) { @@ -64,36 +82,10 @@ object SessionsReducer { } } - is Action.ApproveActive -> respondToSelectedApproval(sessions, ApprovalDecision.APPROVE, note = null) - is Action.RejectActive -> respondToSelectedApproval(sessions, ApprovalDecision.REJECT, note = null) - is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock) else -> sessions to emptyList() } - private fun respondToSelectedApproval( - sessions: SessionsState, - decision: ApprovalDecision, - note: String?, - ): Pair> { - val selectedId = sessions.selectedId ?: return sessions to emptyList() - val selected = sessions.sessions.firstOrNull { it.id == selectedId } ?: return sessions to emptyList() - val pending = selected.pendingApproval ?: return sessions to emptyList() - val updated = sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == selectedId) s.copy(pendingApproval = null) else s - }, - ) - val effect = Effect.SendWs( - ClientMessage.ApprovalResponse( - requestId = ApprovalRequestId(pending.requestId), - decision = decision, - steeringNote = note, - ), - ) - return updated to listOf(effect) - } - private fun filteredSessions(sessions: SessionsState): List = if (sessions.filter.isBlank()) sessions.sessions else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) } @@ -122,23 +114,91 @@ object SessionsReducer { sessions: SessionsState, msg: ServerMessage, clock: () -> Long, - ): Pair> = when (msg) { - is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions) - is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock) - is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock) - is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock) - is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions) - is ServerMessage.StageCompleted -> processStageCompletedMessage(msg, sessions) - is ServerMessage.StageFailed -> processStageFailedMessage(msg, sessions) - is ServerMessage.ToolStarted -> processToolStartedMessage(clock, sessions, msg) - is ServerMessage.InferenceCompleted -> processInferenceCompletedMessage(sessions, msg) - is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions) - is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions) - is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg) - is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg) - is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg) - else -> sessions to emptyList() - }.also { log.debug("processed server message: {}", msg) } + ): Pair> { + val (result, effects) = when (msg) { + is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions) + is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock) + is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock) + is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock) + is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions) + is ServerMessage.StageCompleted -> processStageCompletedMessage(msg, sessions) + is ServerMessage.StageFailed -> processStageFailedMessage(msg, sessions) + is ServerMessage.ToolStarted -> processToolStartedMessage(clock, sessions, msg) + is ServerMessage.InferenceCompleted -> processInferenceCompletedMessage(sessions, msg) + is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions) + is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions) + is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg) + is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg) + is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg) + is ServerMessage.StageToolManifest -> processStageToolManifestMessage(sessions, msg) + else -> sessions to emptyList() + } + val withBg = incrementBackgroundCountIfNonSelected(result, msg) + log.debug("processed server message: {}", msg) + return withBg to effects + } + + private fun sessionIdFromMessage(msg: ServerMessage): String? = when (msg) { + is ServerMessage.SessionStarted -> msg.sessionId.value + is ServerMessage.SessionPaused -> msg.sessionId.value + is ServerMessage.SessionCompleted -> msg.sessionId.value + is ServerMessage.SessionFailed -> msg.sessionId.value + is ServerMessage.StageStarted -> msg.sessionId.value + is ServerMessage.StageCompleted -> msg.sessionId.value + is ServerMessage.StageFailed -> msg.sessionId.value + is ServerMessage.ToolStarted -> msg.sessionId.value + is ServerMessage.InferenceCompleted -> msg.sessionId.value + is ServerMessage.ToolCompleted -> msg.sessionId.value + is ServerMessage.ToolFailed -> msg.sessionId.value + is ServerMessage.ToolRejected -> msg.sessionId.value + is ServerMessage.ApprovalRequired -> msg.sessionId.value + is ServerMessage.SessionSnapshot -> msg.sessionId.value + // Control/non-event messages do not count per ARCH-BADGE-1: + is ServerMessage.StageToolManifest -> null + is ServerMessage.SnapshotComplete -> null + is ServerMessage.ProtocolError -> null + is ServerMessage.ProviderStatusChanged -> null + else -> null + } + + private fun incrementBackgroundCountIfNonSelected( + sessions: SessionsState, + msg: ServerMessage, + ): SessionsState { + val sessionId = sessionIdFromMessage(msg) ?: return sessions + return if (sessionId != sessions.selectedId) { + sessions.copy(backgroundUpdateCount = sessions.backgroundUpdateCount + 1) + } else { + sessions + } + } + + /** TODO(RF-3): If StageToolManifest arrives before SessionStarted, the session + * won't exist in the list and the manifest is silently dropped. Options: + * (a) Buffer the manifest and apply when SessionStarted arrives for this sessionId. + * (b) Create a placeholder SessionSummary from the manifest's sessionId/workflowId. + * Option (b) also requires dedup in processSessionStartedMessage to avoid duplicates. + * Deferred — the spec says manifest arrives on session start, but WebSocket ordering + * is not guaranteed. */ + private fun processStageToolManifestMessage( + sessions: SessionsState, + msg: ServerMessage.StageToolManifest, + ): Pair> { + val toolsByStage = msg.stages.associate { stageDecl -> + stageDecl.stageId to stageDecl.tools.map { toolDecl -> + ToolManifestEntry(name = toolDecl.name, tier = toolDecl.tier) + } + } + return sessions.copy( + sessions = sessions.sessions.map { s -> + if (s.id == msg.sessionId.value) { + s.copy(toolsByStage = toolsByStage) + } else { + s + } + }, + ) to emptyList() + } private fun processSessionSnapshotMessage( clock: () -> Long, @@ -359,19 +419,21 @@ object SessionsReducer { sessions: SessionsState, msg: ServerMessage.SessionFailed, clock: () -> Long, - ): Pair> = touchSession( - sessions, - msg.sessionId.value, - "FAILED", - clock, - ) to emptyList() + ): Pair> { + val result = touchSession(sessions, msg.sessionId.value, "FAILED", clock) + val cleared = if (msg.sessionId.value == result.selectedId) result.copy(selectedId = null) else result + return cleared to emptyList() + } private fun processSessionCompletedMessage( sessions: SessionsState, msg: ServerMessage.SessionCompleted, clock: () -> Long, - ): Pair> = - touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to emptyList() + ): Pair> { + val result = touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) + val cleared = if (msg.sessionId.value == result.selectedId) result.copy(selectedId = null) else result + return cleared to emptyList() + } private fun processSessionPausedMessage( msg: ServerMessage.SessionPaused, @@ -395,6 +457,8 @@ object SessionsReducer { clock: () -> Long, sessions: SessionsState, ): Pair> { + // TODO(RF-3): If StageToolManifest created a placeholder, dedup/replace instead of + // unconditional append to avoid duplicate session entries. val summary = SessionSummary( id = msg.sessionId.value, status = "ACTIVE", diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/DisplayState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/DisplayState.kt new file mode 100644 index 00000000..4301ce46 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/DisplayState.kt @@ -0,0 +1,15 @@ +package com.correx.apps.tui.state + +enum class DisplayState { IDLE, IN_SESSION, APPROVAL } + +val TuiState.displayState: DisplayState + get() { + val selectedId = sessions.selectedId ?: return DisplayState.IDLE + val selectedSession = sessions.sessions.firstOrNull { it.id == selectedId } + val hasApproval = selectedSession?.pendingApproval != null + return when { + approvalDismissed -> DisplayState.IN_SESSION + hasApproval -> DisplayState.APPROVAL + else -> DisplayState.IN_SESSION + } + } 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 index db50f9c4..e1edb7b1 100644 --- 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 @@ -4,4 +4,5 @@ data class SessionsState( val sessions: List = emptyList(), val selectedId: String? = null, val filter: String = "", + val backgroundUpdateCount: Int = 0, ) 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 041635d7..e21e261e 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,8 +1,9 @@ package com.correx.apps.tui.state +import com.correx.apps.server.protocol.ApprovalDecision import com.correx.apps.server.protocol.ServerMessage -enum class InputMode { ROUTER, NAVIGATE, FILTER, STEER } +enum class InputMode { ROUTER, FILTER } enum class ToolDisplayStatus { REQUESTED, STARTED, COMPLETED, FAILED, REJECTED } @@ -28,8 +29,12 @@ data class TuiState( val provider: ProviderState = ProviderState(), val inputMode: InputMode = InputMode.ROUTER, val inputBuffer: String = "", - val approvalOverlayVisible: Boolean = false, - val eventOverlayVisible: Boolean = false, + val eventStripVisible: Boolean = true, + val inputHistory: Map> = emptyMap(), + val inputHistoryIndex: Int = -1, + val savedInputBuffer: String = "", + val pendingDecision: ApprovalDecision? = null, + val approvalDismissed: Boolean = false, val currentModel: String? = null, val providerType: ProviderType = ProviderType.LOCAL, val routerConnected: Boolean = false, @@ -51,6 +56,11 @@ data class TuiState( val cursors: Map = emptyMap(), ) +data class ToolManifestEntry( + val name: String, + val tier: Int, +) + data class SessionSummary( val id: String, val status: String, @@ -63,6 +73,7 @@ data class SessionSummary( val lastOutput: String? = null, val lastResponseText: String? = null, val tools: List = emptyList(), + val toolsByStage: Map> = emptyMap(), val recentEvents: List = emptyList(), val pendingApproval: ApprovalInfo? = null, ) 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 index fea7a36e..8abde7f3 100644 --- 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 @@ -1,117 +1,147 @@ package com.correx.apps.tui.input import com.correx.apps.tui.KeyEvent +import com.correx.apps.tui.state.DisplayState 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 ROUTER mode resolves to OpenNewSessionPrompt`() { - val result = KeyResolver.resolve(KeyEvent.NewSession, InputMode.ROUTER, "") - assertEquals(Action.OpenNewSessionPrompt, result) - } - @Test - fun `command letters in text input mode resolve to null`() { - val result = KeyResolver.resolve(KeyEvent.NewSession, InputMode.FILTER, "") - assertNull(result) - } + private fun resolve( + key: KeyEvent, + displayState: DisplayState = DisplayState.IDLE, + inputMode: InputMode = InputMode.ROUTER, + hasPendingApproval: Boolean = false, + inputText: String = "", + ) = KeyResolver.resolve(key, displayState, inputMode, hasPendingApproval, inputText) - @Test - fun `CharInput in text input mode resolves to AppendChar`() { - val result = KeyResolver.resolve(KeyEvent.CharInput('a'), InputMode.FILTER, "") - assertEquals(Action.AppendChar('a'), result) - } - - @Test - fun `Enter on blank input resolves to null`() { - val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.FILTER, "") - assertNull(result) - } - - @Test - fun `Enter on non-blank input resolves to SubmitInput`() { - val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.FILTER, "test") - assertEquals(Action.SubmitInput, result) - } - - @Test - fun `Escape in text input mode resolves to CancelInput`() { - val result = KeyResolver.resolve(KeyEvent.Escape, InputMode.FILTER, "") - assertEquals(Action.CancelInput, result) - } - - @Test - fun `Backspace in text input mode resolves to Backspace action`() { - val result = KeyResolver.resolve(KeyEvent.Backspace, InputMode.FILTER, "") - assertEquals(Action.Backspace, result) - } + // ── Global actions (work regardless of display state / input mode) ── @Test fun `Quit in ROUTER mode resolves to Quit`() { - val result = KeyResolver.resolve(KeyEvent.Quit, InputMode.ROUTER, "") - assertEquals(Action.Quit, result) + assertEquals(Action.Quit, resolve(KeyEvent.Quit)) } @Test - fun `Quit in text input mode resolves to null`() { - val result = KeyResolver.resolve(KeyEvent.Quit, InputMode.FILTER, "") - assertNull(result) + fun `Quit in FILTER mode resolves to Quit`() { + assertEquals(Action.Quit, resolve(KeyEvent.Quit, inputMode = InputMode.FILTER)) } @Test - fun `Cancel in ROUTER mode resolves to CancelSelectedSession`() { - val result = KeyResolver.resolve(KeyEvent.Cancel, InputMode.ROUTER, "") - assertEquals(Action.CancelSelectedSession, result) + fun `Approve resolves globally to ApproveActive`() { + assertEquals(Action.ApproveActive, resolve(KeyEvent.Approve)) } @Test - fun `Approve in ROUTER mode resolves to ApproveActive`() { - val result = KeyResolver.resolve(KeyEvent.Approve, InputMode.ROUTER, "") - assertEquals(Action.ApproveActive, result) + fun `Reject resolves globally to RejectActive`() { + assertEquals(Action.RejectActive, resolve(KeyEvent.Reject)) + } + + // ── FILTER mode ── + + @Test + fun `NavUp in FILTER mode resolves to NavigateUp`() { + assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, inputMode = InputMode.FILTER)) } @Test - fun `Reject in ROUTER mode resolves to RejectActive`() { - val result = KeyResolver.resolve(KeyEvent.Reject, InputMode.ROUTER, "") - assertEquals(Action.RejectActive, result) + fun `NavDown in FILTER mode resolves to NavigateDown`() { + assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, inputMode = InputMode.FILTER)) } @Test - fun `Steer in ROUTER mode resolves to OpenSteeringPrompt`() { - val result = KeyResolver.resolve(KeyEvent.Steer, InputMode.ROUTER, "") - assertEquals(Action.OpenSteeringPrompt, result) + fun `Enter in FILTER mode on blank input resolves to null`() { + assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER)) } @Test - fun `NavUp in ROUTER mode resolves to NavigateUp`() { - val result = KeyResolver.resolve(KeyEvent.NavUp, InputMode.ROUTER, "") - assertEquals(Action.NavigateUp, result) + fun `Enter in FILTER mode on non-blank input resolves to SubmitInput`() { + assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = "test")) } @Test - fun `NavDown in ROUTER mode resolves to NavigateDown`() { - val result = KeyResolver.resolve(KeyEvent.NavDown, InputMode.ROUTER, "") - assertEquals(Action.NavigateDown, result) + fun `Enter in FILTER mode on whitespace-only input resolves to null`() { + assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = " ")) } @Test - fun `Filter in ROUTER mode cycles to NAVIGATE`() { - val result = KeyResolver.resolve(KeyEvent.Filter, InputMode.ROUTER, "") - assertEquals(Action.CycleMode, result) + fun `Escape in FILTER mode resolves to CancelInput`() { + assertEquals(Action.CancelInput, resolve(KeyEvent.Escape, inputMode = InputMode.FILTER)) } @Test - fun `Enter on whitespace-only input resolves to null`() { - val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.FILTER, " ") - assertNull(result) + fun `Backspace in FILTER mode resolves to Backspace`() { + assertEquals(Action.Backspace, resolve(KeyEvent.Backspace, inputMode = InputMode.FILTER)) + } + + @Test + fun `CharInput in FILTER mode resolves to AppendChar`() { + assertEquals(Action.AppendChar('a'), resolve(KeyEvent.CharInput('a'), inputMode = InputMode.FILTER)) + } + + // ── IDLE display state ── + + @Test + fun `NavUp in IDLE resolves to NavigateUp`() { + assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, DisplayState.IDLE)) + } + + @Test + fun `NavDown in IDLE resolves to NavigateDown`() { + assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, DisplayState.IDLE)) + } + + @Test + fun `Enter in IDLE on blank input resolves to null`() { + assertNull(resolve(KeyEvent.Enter, DisplayState.IDLE)) + } + + @Test + fun `Enter in IDLE on non-blank input resolves to SubmitInput`() { + assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, DisplayState.IDLE, inputText = "wf")) + } + + // ── IN_SESSION display state ── + + @Test + fun `NavUp in IN_SESSION resolves to NavigateUp`() { + assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, DisplayState.IN_SESSION)) + } + + @Test + fun `NavDown in IN_SESSION resolves to NavigateDown`() { + assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, DisplayState.IN_SESSION)) } @Test fun `CharInput in ROUTER mode resolves to AppendChar`() { - val result = KeyResolver.resolve(KeyEvent.CharInput('x'), InputMode.ROUTER, "") - assertEquals(Action.AppendChar('x'), result) + assertEquals(Action.AppendChar('x'), resolve(KeyEvent.CharInput('x'))) + } + + @Test + fun `ShowPendingApproval in IN_SESSION with pending approval emits action`() { + assertEquals( + Action.ShowPendingApproval, + resolve(KeyEvent.ShowPendingApproval, DisplayState.IN_SESSION, hasPendingApproval = true), + ) + } + + @Test + fun `ShowPendingApproval in IN_SESSION without pending approval resolves to null`() { + assertNull(resolve(KeyEvent.ShowPendingApproval, DisplayState.IN_SESSION, hasPendingApproval = false)) + } + + // ── APPROVAL display state ── + + @Test + fun `Enter in APPROVAL resolves to SubmitApprovalDecision`() { + assertEquals(Action.SubmitApprovalDecision, resolve(KeyEvent.Enter, DisplayState.APPROVAL)) + } + + @Test + fun `Escape in APPROVAL resolves to CancelInput`() { + assertEquals(Action.CancelInput, resolve(KeyEvent.Escape, DisplayState.APPROVAL)) } } diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt index 1311e841..313c16b8 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt @@ -10,26 +10,11 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent class TambouiKeyMapperTest { - // --- Ctrl-C always → Quit --- + // --- Ctrl-C always → Cancel --- @Test - fun `ctrl-c maps to Quit in InputMode ROUTER`() { - assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code))) - } - - @Test - fun `ctrl-c maps to Quit in InputMode FILTER`() { - assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code))) - } - - @Test - fun `ctrl-c maps to Quit in InputMode NAVIGATE`() { - assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code))) - } - - @Test - fun `ctrl-c maps to Quit in InputMode STEER`() { - assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code))) + fun `ctrl-c maps to Cancel`() { + assertEquals(KeyEvent.Cancel, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code))) } // --- Ctrl+key → keybind events --- @@ -55,18 +40,13 @@ class TambouiKeyMapperTest { } @Test - fun `ctrl-s maps to Steer`() { - assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code))) + fun `ctrl-h maps to ShowPendingApproval`() { + assertEquals(KeyEvent.ShowPendingApproval, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code))) } @Test - fun `alt-h maps to ToggleApprovalOverlay`() { - assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'h'.code))) - } - - @Test - fun `ctrl-e maps to ToggleEventOverlay`() { - assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code))) + fun `ctrl-e maps to ToggleEventStrip`() { + assertEquals(KeyEvent.ToggleEventStrip, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code))) } @Test @@ -109,12 +89,7 @@ class TambouiKeyMapperTest { } @Test - fun `bare q maps to CharInput in STEER`() { - assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'))) - } - - @Test - fun `bare q maps to CharInput in NAVIGATE`() { + fun `bare q maps to CharInput`() { assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'))) } @@ -146,13 +121,8 @@ class TambouiKeyMapperTest { } @Test - fun `TAB maps to Filter`() { - assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB))) - } - - @Test - fun `TAB maps to Filter in FILTER mode`() { - assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB))) + fun `TAB maps to Tab`() { + assertEquals(KeyEvent.Tab, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB))) } // --- ISO control chars → null --- 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 index 3f6df480..b548974b 100644 --- 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 @@ -1,12 +1,7 @@ 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.SessionSummary -import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.TuiState import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -14,30 +9,6 @@ import org.junit.jupiter.api.Test class InputReducerTest { - private val activeApproval = ApprovalInfo( - requestId = "req-1", - sessionId = "sess-1", - tier = "HIGH", - riskSummary = "HIGH", - toolName = "bash", - preview = null, - ) - - private val sessionWithApproval = SessionSummary( - id = "sess-1", - status = "running", - workflowId = "wf", - lastEventAt = 0L, - pendingApproval = activeApproval, - ) - - private fun stateWithActiveApproval(inputMode: InputMode = InputMode.ROUTER, inputBuffer: String = "") = - TuiState( - inputMode = inputMode, - inputBuffer = inputBuffer, - sessions = SessionsState(sessions = listOf(sessionWithApproval), selectedId = "sess-1"), - ) - private fun reduce( state: TuiState = TuiState(), action: Action, @@ -51,23 +22,6 @@ class InputReducerTest { assertTrue(effects.isEmpty()) } - @Test - fun `OpenSteeringPrompt sets mode to STEER when approval active`() { - val initial = stateWithActiveApproval() - val (state, effects) = reduce(state = initial, action = Action.OpenSteeringPrompt) - assertEquals(InputMode.STEER, state.inputMode) - assertEquals("", state.inputBuffer) - assertTrue(effects.isEmpty()) - } - - @Test - fun `OpenSteeringPrompt is no-op when no active approval`() { - val initial = TuiState() - val (state, effects) = reduce(state = initial, action = Action.OpenSteeringPrompt) - assertEquals(initial, state) - assertTrue(effects.isEmpty()) - } - @Test fun `AppendChar appends character to buffer`() { val (state, effects) = reduce( @@ -109,64 +63,32 @@ class InputReducerTest { } @Test - fun `SubmitInput in ROUTER mode emits StartSession effect`() { + fun `SubmitInput clears input buffer with no effects`() { val (state, effects) = reduce( state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow"), action = Action.SubmitInput, ) - assertEquals(InputMode.ROUTER, state.inputMode) - assertEquals(1, effects.size) - val effect = effects[0] as Effect.SendWs - val msg = effect.message as ClientMessage.StartSession - assertEquals("my-workflow", msg.workflowId) + assertEquals("", state.inputBuffer) + assertTrue(effects.isEmpty()) } @Test - fun `SubmitInput in ROUTER mode with blank text emits no effect`() { + fun `SubmitInput with blank text clears input buffer`() { val (state, effects) = reduce( state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = " "), action = Action.SubmitInput, ) - assertEquals(InputMode.ROUTER, state.inputMode) + assertEquals("", state.inputBuffer) assertTrue(effects.isEmpty()) } @Test - fun `SubmitInput in STEER mode with active approval emits ApprovalResponse APPROVE with steering note`() { - val (state, effects) = reduce( - state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "steer this way"), - action = Action.SubmitInput, - ) - assertEquals(InputMode.ROUTER, state.inputMode) - assertEquals(1, effects.size) - val effect = effects[0] as Effect.SendWs - val msg = effect.message as ClientMessage.ApprovalResponse - assertEquals(ApprovalDecision.APPROVE, msg.decision) - assertEquals("steer this way", msg.steeringNote) - assertEquals("req-1", msg.requestId.value) - } - - @Test - fun `SubmitInput in STEER mode clears pendingApproval atomically with the response effect`() { - val (state, effects) = reduce( - state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "go left"), - action = Action.SubmitInput, - ) - assertEquals(null, state.sessions.sessions[0].pendingApproval) - assertEquals(1, effects.size) - val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse - assertEquals(ApprovalDecision.APPROVE, msg.decision) - assertEquals("go left", msg.steeringNote) - assertEquals("req-1", msg.requestId.value) - } - - @Test - fun `SubmitInput in FILTER mode clears input with no effects`() { + fun `SubmitInput in FILTER mode clears buffer with no effects`() { val (state, effects) = reduce( state = TuiState(inputMode = InputMode.FILTER, inputBuffer = "myfilter"), action = Action.SubmitInput, ) - assertEquals(InputMode.ROUTER, state.inputMode) + assertEquals("", state.inputBuffer) 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 index 44a00b08..5d386be4 100644 --- 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 @@ -5,10 +5,12 @@ 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.ConnectionState +import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.TuiState +import com.correx.apps.tui.state.displayState import com.correx.core.approvals.Tier import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.SessionId @@ -53,29 +55,29 @@ class RootReducerTest { } @Test - fun `NavigateUp with sessions adjusts selected id`() { - val state0 = TuiState(snapshotPhase = false) - val startMsg = com.correx.apps.server.protocol.ServerMessage.SessionStarted( - sessionId = com.correx.core.events.types.SessionId("s1"), - workflowId = "wf", - sequence = 1L, - sessionSequence = 1L, + fun `NavigateDown in IDLE with no selection selects first session`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1"), session("s2")), + selectedId = null, + ), ) - 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", - sequence = 2L, - sessionSequence = 1L, + val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock) + assertEquals("s1", result.sessions.selectedId) + } + + @Test + fun `NavigateUp in IDLE selects last session when none selected`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("a"), session("b"), session("c")), + selectedId = null, + ), ) - val (state2, _) = RootReducer.reduce(state1, Action.ServerEventReceived(startMsg2), fixedClock) - // selected is s1 (first); navigating down should move to s2 - val (state3, _) = RootReducer.reduce( - state2.copy(inputMode = InputMode.NAVIGATE), - Action.NavigateDown, - fixedClock, - ) - assertEquals("s2", state3.sessions.selectedId) + val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock) + assertEquals("c", result.sessions.selectedId) } @Test @@ -138,6 +140,151 @@ class RootReducerTest { assertTrue(effects.isEmpty()) } + // ── Input history navigation (ARCH-HISTORY-2/3) ── + + @Test + fun `NavigateUp in IN_SESSION at index -1 saves buffer and loads last history entry`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1")), + selectedId = "s1", + ), + inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")), + inputHistoryIndex = -1, + inputBuffer = "current input", + savedInputBuffer = "", + ) + val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock) + assertEquals(2, result.inputHistoryIndex) + assertEquals("msg3", result.inputBuffer) + assertEquals("current input", result.savedInputBuffer) + } + + @Test + fun `NavigateUp in IN_SESSION from index above 0 decrements index and loads entry`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1")), + selectedId = "s1", + ), + inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")), + inputHistoryIndex = 2, + inputBuffer = "msg3", + savedInputBuffer = "original", + ) + val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock) + assertEquals(1, result.inputHistoryIndex) + assertEquals("msg2", result.inputBuffer) + assertEquals("original", result.savedInputBuffer) + } + + @Test + fun `NavigateUp in IN_SESSION at index 0 stays at oldest entry`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1")), + selectedId = "s1", + ), + inputHistory = mapOf("s1" to listOf("msg1", "msg2")), + inputHistoryIndex = 0, + inputBuffer = "msg1", + ) + val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock) + assertEquals(0, result.inputHistoryIndex) + assertEquals("msg1", result.inputBuffer) + } + + @Test + fun `NavigateUp in IN_SESSION with empty history is no-op`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1")), + selectedId = "s1", + ), + inputHistory = emptyMap(), + inputHistoryIndex = -1, + inputBuffer = "still here", + ) + val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock) + assertEquals(-1, result.inputHistoryIndex) + assertEquals("still here", result.inputBuffer) + } + + @Test + fun `NavigateDown in IN_SESSION at index -1 stays at current buffer`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1")), + selectedId = "s1", + ), + inputHistory = mapOf("s1" to listOf("msg1", "msg2")), + inputHistoryIndex = -1, + inputBuffer = "typing...", + ) + val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock) + assertEquals(-1, result.inputHistoryIndex) + assertEquals("typing...", result.inputBuffer) + } + + @Test + fun `NavigateDown in IN_SESSION at last entry restores savedInputBuffer and resets index`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1")), + selectedId = "s1", + ), + inputHistory = mapOf("s1" to listOf("msg1", "msg2")), + inputHistoryIndex = 1, + inputBuffer = "msg2", + savedInputBuffer = "my saved text", + ) + val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock) + assertEquals(-1, result.inputHistoryIndex) + assertEquals("my saved text", result.inputBuffer) + } + + @Test + fun `NavigateDown in IN_SESSION from below last entry increments index`() { + val state = TuiState( + snapshotPhase = false, + sessions = SessionsState( + sessions = listOf(session("s1")), + selectedId = "s1", + ), + inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")), + inputHistoryIndex = 0, + inputBuffer = "msg1", + ) + val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock) + assertEquals(1, result.inputHistoryIndex) + assertEquals("msg2", result.inputBuffer) + } + + @Test + fun `NavigateUp in IN_SESSION with FILTER mode does not navigate history`() { + val state = TuiState( + snapshotPhase = false, + inputMode = InputMode.FILTER, + sessions = SessionsState( + sessions = listOf(session("s1")), + selectedId = "s1", + ), + inputHistory = mapOf("s1" to listOf("msg1", "msg2")), + inputHistoryIndex = -1, + inputBuffer = "dont touch", + ) + val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock) + // In FILTER mode, history nav should not activate + assertEquals(-1, result.inputHistoryIndex) + assertEquals("dont touch", result.inputBuffer) + } + @Test fun `AppendChar followed by Backspace returns to original buffer`() { val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab") @@ -161,7 +308,7 @@ class RootReducerTest { private fun session(id: String) = SessionSummary(id = id, status = "ACTIVE", workflowId = "wf", lastEventAt = 0L) @Test - fun `ApprovalRequired on selected session auto-opens approvalOverlayVisible`() { + fun `ApprovalRequired on selected session transitions display state to APPROVAL`() { val initial = TuiState( snapshotPhase = false, sessions = SessionsState(sessions = listOf(session("s1")), selectedId = "s1"), @@ -169,12 +316,12 @@ class RootReducerTest { val (state, _) = RootReducer.reduce( initial, Action.ServerEventReceived(approvalRequiredMsg("s1")), fixedClock, ) - assertEquals(true, state.approvalOverlayVisible) + assertEquals(DisplayState.APPROVAL, state.displayState) assertEquals("r1", state.sessions.sessions[0].pendingApproval?.requestId) } @Test - fun `ApprovalRequired on non-selected session does not open approvalOverlayVisible`() { + fun `ApprovalRequired on non-selected session does not transition to APPROVAL`() { val initial = TuiState( snapshotPhase = false, sessions = SessionsState( @@ -185,7 +332,7 @@ class RootReducerTest { val (state, _) = RootReducer.reduce( initial, Action.ServerEventReceived(approvalRequiredMsg("s2")), fixedClock, ) - assertEquals(false, state.approvalOverlayVisible) + assertEquals(DisplayState.IN_SESSION, state.displayState) assertEquals(null, state.sessions.sessions.find { it.id == "s1" }?.pendingApproval) assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId) } 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 index ddff612d..33aac541 100644 --- 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 @@ -8,6 +8,7 @@ import com.correx.apps.server.protocol.RiskSummaryDto import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.SessionStateDto import com.correx.apps.tui.input.Action +import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.ApprovalInfo import com.correx.apps.tui.state.SessionSummary @@ -35,15 +36,16 @@ class SessionsReducerTest { private fun reduce( sessions: SessionsState = SessionsState(), + displayState: DisplayState = DisplayState.IDLE, inputMode: InputMode = InputMode.ROUTER, inputText: String = "", action: Action, - ) = SessionsReducer.reduce(sessions, inputMode, inputText, action, fixedClock) + ) = SessionsReducer.reduce(sessions, displayState, 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, inputMode = InputMode.NAVIGATE, action = Action.NavigateUp) + val (state, effects) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateUp) assertEquals("c", state.selectedId) assertTrue(effects.isEmpty()) } @@ -51,14 +53,14 @@ class SessionsReducerTest { @Test fun `NavigateDown advances selection`() { val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "a") - val (state, _) = reduce(sessions = s, inputMode = InputMode.NAVIGATE, action = Action.NavigateDown) + val (state, _) = reduce(sessions = s, displayState = DisplayState.IDLE, 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, inputMode = InputMode.NAVIGATE, action = Action.NavigateDown) + val (state, _) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateDown) assertEquals("a", state.selectedId) } @@ -318,32 +320,25 @@ class SessionsReducerTest { ) @Test - fun `ApproveActive emits ApprovalResponse APPROVE and clears pendingApproval`() { + fun `ApproveActive is no-op in SessionsReducer (handled by RootReducer pendingDecision)`() { val s = SessionsState( sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())), selectedId = "s1", ) val (state, effects) = reduce(sessions = s, action = Action.ApproveActive) - assertEquals(null, state.sessions[0].pendingApproval) - assertEquals(1, effects.size) - val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse - assertEquals(ApprovalDecision.APPROVE, msg.decision) - assertEquals("req-1", msg.requestId.value) - assertEquals(null, msg.steeringNote) + assertEquals(s, state) + assertTrue(effects.isEmpty()) } @Test - fun `RejectActive emits ApprovalResponse REJECT and clears pendingApproval`() { + fun `RejectActive is no-op in SessionsReducer (handled by RootReducer pendingDecision)`() { val s = SessionsState( sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())), selectedId = "s1", ) val (state, effects) = reduce(sessions = s, action = Action.RejectActive) - assertEquals(null, state.sessions[0].pendingApproval) - assertEquals(1, effects.size) - val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse - assertEquals(ApprovalDecision.REJECT, msg.decision) - assertEquals("req-1", msg.requestId.value) + assertEquals(s, state) + assertTrue(effects.isEmpty()) } @Test