feat(tui): session display model with DisplayState, input history nav, and StageToolManifest

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).
This commit is contained in:
2026-05-26 01:46:14 +04:00
parent 2e3b8e599c
commit f6ef028883
32 changed files with 1180 additions and 701 deletions
@@ -131,6 +131,7 @@ fun main() {
routerFacade = routerFacade, routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository, orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository, approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
approvalConfig = correxConfig.approval, approvalConfig = correxConfig.approval,
) )
module.start() module.start()
@@ -13,6 +13,7 @@ import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.router.RouterFacade import com.correx.core.router.RouterFacade
import com.correx.core.sessions.DefaultSessionRepository import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.tools.registry.ToolRegistry
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -32,6 +33,7 @@ class ServerModule(
val routerFacade: RouterFacade, val routerFacade: RouterFacade,
val orchestrationRepository: OrchestrationRepository, val orchestrationRepository: OrchestrationRepository,
val approvalRepository: DefaultApprovalRepository, val approvalRepository: DefaultApprovalRepository,
val toolRegistry: ToolRegistry,
approvalConfig: ApprovalConfig = ApprovalConfig(), approvalConfig: ApprovalConfig = ApprovalConfig(),
// Long-lived scope owned by the module — backs the ApprovalCoordinator timers // Long-lived scope owned by the module — backs the ApprovalCoordinator timers
// and event-store subscription. SupervisorJob so one failure doesn't kill the // and event-store subscription. SupervisorJob so one failure doesn't kill the
@@ -3,18 +3,24 @@ package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ApprovalDto import com.correx.apps.server.protocol.ApprovalDto
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.SessionStateDto 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.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.orchestration.OrchestrationStatus import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.tools.registry.ToolRegistry
class SessionEventBridge( class SessionEventBridge(
private val eventStore: EventStore, private val eventStore: EventStore,
private val artifactStore: ArtifactStore, private val artifactStore: ArtifactStore,
private val orchestrationRepository: OrchestrationRepository, private val orchestrationRepository: OrchestrationRepository,
private val approvalRepository: DefaultApprovalRepository, private val approvalRepository: DefaultApprovalRepository,
private val workflowRegistry: WorkflowRegistry,
private val toolRegistry: ToolRegistry,
private val send: suspend (ServerMessage) -> Unit, private val send: suspend (ServerMessage) -> Unit,
) { ) {
suspend fun replaySnapshot() { suspend fun replaySnapshot() {
@@ -46,6 +52,24 @@ class SessionEventBridge(
lastSequence = lastGlobal, lastSequence = lastGlobal,
lastSessionSequence = lastSession, 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) send(ServerMessage.SnapshotComplete)
} }
@@ -40,3 +40,15 @@ enum class PauseReason {
APPROVAL_PENDING, APPROVAL_PENDING,
USER_REQUESTED, USER_REQUESTED,
} }
@Serializable
data class StageToolDecl(
val stageId: String,
val tools: List<ToolDecl>,
)
@Serializable
data class ToolDecl(
val name: String,
val tier: Int,
)
@@ -211,6 +211,16 @@ sealed interface ServerMessage {
// -- System / infra -- // -- System / infra --
@Serializable
@SerialName("stage.tool_manifest")
data class StageToolManifest(
val sessionId: SessionId,
val workflowId: String,
val stages: List<StageToolDecl>,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
@Serializable @Serializable
@SerialName("provider.status_changed") @SerialName("provider.status_changed")
data class ProviderStatusChanged( data class ProviderStatusChanged(
@@ -7,6 +7,8 @@ import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ProtocolSerializer import com.correx.apps.server.protocol.ProtocolSerializer
import com.correx.apps.server.protocol.ProviderHealthDto import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.apps.server.protocol.ServerMessage 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.EventMetadata
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.StoredEvent
@@ -47,9 +49,11 @@ class GlobalStreamHandler(private val module: ServerModule) {
val bridge = SessionEventBridge( val bridge = SessionEventBridge(
eventStore = module.eventStore, eventStore = module.eventStore,
artifactStore = module.artifactStore, artifactStore = module.artifactStore,
send = sendFrame,
orchestrationRepository = module.orchestrationRepository, orchestrationRepository = module.orchestrationRepository,
approvalRepository = module.approvalRepository, approvalRepository = module.approvalRepository,
workflowRegistry = module.workflowRegistry,
toolRegistry = module.toolRegistry,
send = sendFrame,
) )
val mapper = DomainEventMapper(module.artifactStore) val mapper = DomainEventMapper(module.artifactStore)
@@ -201,6 +205,23 @@ class GlobalStreamHandler(private val module: ServerModule) {
sessionSequence = 0L, sessionSequence = 0L,
) )
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started))) 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,
),
)))
} }
} }
@@ -1,6 +1,7 @@
package com.correx.apps.server.bridge package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ServerMessage 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.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository 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.events.types.StageId
import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer 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 com.correx.core.sessions.projections.replay.EventReplayer
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -98,6 +101,16 @@ class SessionEventBridgeTest {
), ),
) )
private val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<com.correx.apps.server.registry.WorkflowSummary> = 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<Tool> = emptyList()
}
@Test @Test
fun `replaySnapshot sends mapped messages for all events`() = runTest { fun `replaySnapshot sends mapped messages for all events`() = runTest {
val events = listOf( val events = listOf(
@@ -112,6 +125,8 @@ class SessionEventBridgeTest {
noopArtifactStore, noopArtifactStore,
orchRepo, orchRepo,
approvalRepository, approvalRepository,
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) } ) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
@@ -138,6 +153,8 @@ class SessionEventBridgeTest {
noopArtifactStore, noopArtifactStore,
orchRepo, orchRepo,
approvalRepository, approvalRepository,
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) } ) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
@@ -162,6 +179,8 @@ class SessionEventBridgeTest {
noopArtifactStore, noopArtifactStore,
activeOrchestrationRepository(), activeOrchestrationRepository(),
approvalRepository, approvalRepository,
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) } ) { sent.add(it) }
bridge.streamLive(sessionId) bridge.streamLive(sessionId)
@@ -181,7 +200,7 @@ class SessionEventBridgeTest {
fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest { fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest {
val store = fakeEventStore(allEventsList = emptyList()) val store = fakeEventStore(allEventsList = emptyList())
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) } val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
assertEquals(1, sent.size) assertEquals(1, sent.size)
assertEquals(ServerMessage.SnapshotComplete, sent[0]) assertEquals(ServerMessage.SnapshotComplete, sent[0])
@@ -192,7 +211,7 @@ class SessionEventBridgeTest {
val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L)) val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
val store = fakeEventStore(allEventsList = events) val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) } val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
assertEquals(ServerMessage.SnapshotComplete, sent.last()) assertEquals(ServerMessage.SnapshotComplete, sent.last())
} }
@@ -207,7 +226,7 @@ class SessionEventBridgeTest {
override suspend fun lastGlobalSequence(): Long = 5L override suspend fun lastGlobalSequence(): Long = 5L
} }
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) } val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(5L, snapshot.lastSequence) assertEquals(5L, snapshot.lastSequence)
@@ -221,7 +240,7 @@ class SessionEventBridgeTest {
) )
val store = fakeEventStore(allEventsList = events) val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) } val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(3L, snapshot.lastSessionSequence) assertEquals(3L, snapshot.lastSessionSequence)
@@ -240,6 +259,8 @@ class SessionEventBridgeTest {
noopArtifactStore, noopArtifactStore,
activeOrchestrationRepository(), activeOrchestrationRepository(),
approvalRepository, approvalRepository,
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) } ) { sent.add(it) }
channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L)) channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
@@ -3,6 +3,7 @@ package com.correx.apps.server.ws
import com.correx.apps.server.bridge.DomainEventMapper import com.correx.apps.server.bridge.DomainEventMapper
import com.correx.apps.server.bridge.SessionEventBridge import com.correx.apps.server.bridge.SessionEventBridge
import com.correx.apps.server.protocol.ServerMessage 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.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository 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.events.types.StageId
import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer 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 com.correx.core.sessions.projections.replay.EventReplayer
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
@@ -103,6 +106,16 @@ class GlobalStreamHandlerTest {
}, },
) )
private val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<com.correx.apps.server.registry.WorkflowSummary> = 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<Tool> = emptyList()
}
private fun fakeApprovalRepository(store: EventStore): DefaultApprovalRepository = private fun fakeApprovalRepository(store: EventStore): DefaultApprovalRepository =
DefaultApprovalRepository( DefaultApprovalRepository(
DefaultEventReplayer( DefaultEventReplayer(
@@ -121,7 +134,7 @@ class GlobalStreamHandlerTest {
): Pair<SessionEventBridge, Channel<ServerMessage>> { ): Pair<SessionEventBridge, Channel<ServerMessage>> {
val received = Channel<ServerMessage>(Channel.UNLIMITED) val received = Channel<ServerMessage>(Channel.UNLIMITED)
val approvals = fakeApprovalRepository(store) 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 return bridge to received
} }
@@ -168,7 +181,7 @@ class GlobalStreamHandlerTest {
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L) val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L)
val approvals = fakeApprovalRepository(store) val approvals = fakeApprovalRepository(store)
val received = Channel<ServerMessage>(Channel.UNLIMITED) val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) {
received.send(it) received.send(it)
} }
val mapper = DomainEventMapper(noopArtifactStore) val mapper = DomainEventMapper(noopArtifactStore)
@@ -211,7 +224,7 @@ class GlobalStreamHandlerTest {
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L) val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
val approvals = fakeApprovalRepository(store) val approvals = fakeApprovalRepository(store)
val received = Channel<ServerMessage>(Channel.UNLIMITED) val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) {
received.send(it) received.send(it)
} }
val mapper = DomainEventMapper(noopArtifactStore) val mapper = DomainEventMapper(noopArtifactStore)
@@ -260,7 +273,7 @@ class GlobalStreamHandlerTest {
} }
} }
val approvals = fakeApprovalRepository(store) 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 mapper = DomainEventMapper(noopArtifactStore)
val job = launch { val job = launch {
@@ -278,7 +291,7 @@ class GlobalStreamHandlerTest {
val liveFlow = MutableSharedFlow<StoredEvent>() val liveFlow = MutableSharedFlow<StoredEvent>()
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L) val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
val approvals = fakeApprovalRepository(store) 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 mapper = DomainEventMapper(noopArtifactStore)
val job = launch { val job = launch {
@@ -6,15 +6,15 @@ sealed class KeyEvent {
object Cancel : KeyEvent() object Cancel : KeyEvent()
object Approve : KeyEvent() object Approve : KeyEvent()
object Reject : KeyEvent() object Reject : KeyEvent()
object Steer : KeyEvent()
object NavUp : KeyEvent() object NavUp : KeyEvent()
object NavDown : KeyEvent() object NavDown : KeyEvent()
object Filter : KeyEvent() object Filter : KeyEvent()
object Tab : KeyEvent() object Tab : KeyEvent()
object ToggleApprovalOverlay : KeyEvent()
object ToggleEventOverlay : KeyEvent()
object Enter : KeyEvent() object Enter : KeyEvent()
object Backspace : KeyEvent() object Backspace : KeyEvent()
object Escape : KeyEvent() object Escape : KeyEvent()
object ToggleEventStrip : KeyEvent()
object ReturnToSessionList : KeyEvent()
object ShowPendingApproval : KeyEvent()
data class CharInput(val ch: Char) : KeyEvent() data class CharInput(val ch: Char) : KeyEvent()
} }
@@ -1,18 +1,21 @@
package com.correx.apps.tui 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.inputBarWidget
import com.correx.apps.tui.components.routerPanelWidget import com.correx.apps.tui.components.routerPanelWidget
import com.correx.apps.tui.components.sessionListWidget import com.correx.apps.tui.components.sessionListWidget
import com.correx.apps.tui.components.statusBarWidget 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.Action
import com.correx.apps.tui.input.KeyResolver import com.correx.apps.tui.input.KeyResolver
import com.correx.apps.tui.input.mapKey import com.correx.apps.tui.input.mapKey
import com.correx.apps.tui.reducer.Effect import com.correx.apps.tui.reducer.Effect
import com.correx.apps.tui.reducer.EffectDispatcher import com.correx.apps.tui.reducer.EffectDispatcher
import com.correx.apps.tui.reducer.RootReducer 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.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.ConnectionEvent
import com.correx.apps.tui.ws.TuiWsClient import com.correx.apps.tui.ws.TuiWsClient
import dev.tamboui.layout.Constraint 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 DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 1 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 const val INPUT_HEIGHT = 4
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main") private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
@@ -90,7 +93,10 @@ fun main(args: Array<String>) {
val handler = EventHandler { event, _ -> val handler = EventHandler { event, _ ->
if (event is TambouiKeyEvent) { if (event is TambouiKeyEvent) {
mapKey(event) 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) ?.let(::dispatch)
} }
true true
@@ -110,29 +116,72 @@ internal suspend fun dispatchAll(dispatcher: EffectDispatcher, effects: List<Eff
} }
private fun render(frame: Frame, state: TuiState) { private fun render(frame: Frame, state: TuiState) {
val area = frame.area() when (state.displayState) {
DisplayState.IDLE -> 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() val vertRects = Layout.vertical()
.constraints( .constraints(
Constraint.length(STATUS_HEIGHT), Constraint.length(STATUS_HEIGHT),
Constraint.length(TOP_ROW_HEIGHT),
Constraint.fill(), Constraint.fill(),
Constraint.length(INPUT_HEIGHT), Constraint.length(INPUT_HEIGHT),
) )
.split(area) .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(statusBarWidget(state), vertRects[0])
frame.renderWidget(sessionListWidget(state), topRects[0]) frame.renderWidget(sessionListWidget(state), vertRects[1])
frame.renderWidget(activeSessionWidget(state), topRects[1]) frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[2])
frame.renderWidget(toolsPanelWidget(state), topRects[2]) }
frame.renderWidget(routerPanelWidget(state), vertRects[2])
frame.renderWidget(inputBarWidget(state), vertRects[3]) 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])
} }
@@ -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<Line> {
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)
}
}
@@ -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<Line> {
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()
}
@@ -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<Line> {
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: <type in input bar>", 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()
}
@@ -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<Line> {
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()
}
@@ -1,5 +1,7 @@
package com.correx.apps.tui.components 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.InputMode
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style 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.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph 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 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 val sessionName = state.sessions.sessions
.firstOrNull { it.id == state.sessions.selectedId } .firstOrNull { it.id == state.sessions.selectedId }
@@ -22,12 +22,21 @@ fun inputBarWidget(state: TuiState): Paragraph {
?: state.sessions.selectedId?.take(6) ?: state.sessions.selectedId?.take(6)
?: "(no session)" ?: "(no session)"
val (row1, row2) = when (state.inputMode) { val (row1, row2) = when (displayState) {
InputMode.ROUTER -> createRowsForRouter(state, dimStyle, sessionName, hasApproval, hasSession) DisplayState.IDLE -> when (state.inputMode) {
InputMode.NAVIGATE -> createRowsForNavigate(dimStyle, sessionName, hasApproval, hasSession) InputMode.ROUTER -> createRowsForIdleRouter(state, dimStyle, sessionName)
InputMode.FILTER -> createRowsForFilter(state, dimStyle) InputMode.FILTER -> createRowsForFilter(state, dimStyle)
InputMode.STEER -> createRowsForSteer(state, dimStyle, sessionName) }
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() 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() return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build()
} }
private fun createRowsForSteer( private fun createRowsForIdleRouter(
state: TuiState, state: TuiState,
dimStyle: Style?, dimStyle: Style?,
sessionName: String, sessionName: String,
@@ -47,9 +56,50 @@ private fun createRowsForSteer(
val cursor = if (state.inputBuffer.isNotEmpty()) { val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer)) Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else { } 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<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Ask anything…", dimStyle))
}
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<Line?, Line?> {
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( private fun createRowsForFilter(
@@ -63,48 +113,3 @@ private fun createRowsForFilter(
} }
return cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle)) return cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle))
} }
private fun createRowsForNavigate(
dimStyle: Style?,
sessionName: String,
hasApproval: Boolean,
hasSession: Boolean,
): Pair<Line?, Line?> {
val row1Line = Line.from(Span.styled(" ↑↓ sessions enter select", dimStyle))
val hints = buildList {
add("$sessionName · navigate")
add("tab router")
if (hasApproval) {
add("ctrl+a approve"); add("ctrl+r reject")
}
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
return row1Line to Line.from(Span.styled(hints, dimStyle))
}
private fun createRowsForRouter(
state: TuiState,
dimStyle: Style?,
sessionName: String,
hasApproval: Boolean,
hasSession: Boolean,
): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Ask anything…", dimStyle))
}
val hints = buildList {
add("$sessionName · router")
add("tab navigate")
if (hasApproval) {
add("ctrl+a approve"); add("ctrl+r reject")
}
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
return cursor to Line.from(Span.styled(hints, dimStyle))
}
@@ -1,7 +1,6 @@
package com.correx.apps.tui.components package com.correx.apps.tui.components
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.selectedPendingApproval
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
@@ -14,7 +13,7 @@ import dev.tamboui.widgets.paragraph.Paragraph
fun routerPanelWidget(state: TuiState): Paragraph { fun routerPanelWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray() val dimStyle = Style.create().dim().gray()
val mainLines = buildList<Line> { val lines = buildList<Line> {
if (!state.routerConnected) { if (!state.routerConnected) {
add(Line.from(Span.styled("not connected — epic 14", dimStyle))) add(Line.from(Span.styled("not connected — epic 14", dimStyle)))
} else { } else {
@@ -24,44 +23,11 @@ fun routerPanelWidget(state: TuiState): Paragraph {
} }
} }
val activeApproval = state.selectedPendingApproval()
val overlayLines: List<Line> = 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() val block = Block.builder()
.title("router") .title("router")
.borders(Borders.ALL) .borders(Borders.ALL)
.borderType(BorderType.ROUNDED) .borderType(BorderType.ROUNDED)
.build() .build()
return Paragraph.builder().text(Text.from(allLines)).block(block).build() return Paragraph.builder().text(Text.from(lines)).block(block).build()
} }
@@ -1,7 +1,9 @@
package com.correx.apps.tui.components 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.ProviderType
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
@@ -10,12 +12,12 @@ import dev.tamboui.widgets.paragraph.Paragraph
fun statusBarWidget(state: TuiState): Paragraph { fun statusBarWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray() val dimStyle = Style.create().dim().gray()
val sep = Span.styled(" ", dimStyle) val sep = Span.styled(" \u2502 ", dimStyle)
val connSpan = if (state.connection.connected) { val connSpan = if (state.connection.connected) {
Span.styled("", Style.create().green()) Span.styled("\u25CF", Style.create().green())
} else { } 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") 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) 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<Span> { val spans = buildList<Span> {
add(connSpan) add(connSpan)
add(connLabel) add(connLabel)
@@ -36,9 +69,17 @@ fun statusBarWidget(state: TuiState): Paragraph {
add(modelSpan) add(modelSpan)
add(sep) add(sep)
add(sessionsSpan) 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) { if (state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval != null) {
add(sep) add(sep)
add(Span.styled("[ approval]", Style.create().yellow())) add(Span.styled("[\u26A0 approval]", Style.create().yellow()))
} }
} }
@@ -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<Line> {
for (tool in tools) {
val warn = tool.tier >= 2 || tool.status == ToolDisplayStatus.REQUESTED
val nameSpans = buildList<Span> {
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()
}
@@ -8,7 +8,6 @@ sealed interface Action {
data object CancelSelectedSession : Action data object CancelSelectedSession : Action
data object ApproveActive : Action data object ApproveActive : Action
data object RejectActive : Action data object RejectActive : Action
data object OpenSteeringPrompt : Action
data object NavigateUp : Action data object NavigateUp : Action
data object NavigateDown : Action data object NavigateDown : Action
data object OpenFilter : Action data object OpenFilter : Action
@@ -16,13 +15,14 @@ sealed interface Action {
data object Backspace : Action data object Backspace : Action
data object SubmitInput : Action data object SubmitInput : Action
data object CancelInput : 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 class ServerEventReceived(val message: ServerMessage) : Action
data object Connected : Action data object Connected : Action
data object Disconnected : Action data object Disconnected : Action
data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : 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 CycleMode : Action
data object NoOp : Action data object NoOp : Action
} }
@@ -1,56 +1,71 @@
package com.correx.apps.tui.input package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
object KeyResolver { object KeyResolver {
fun resolve(key: KeyEvent, mode: InputMode, inputText: String): Action? { fun resolve(
val global = resolveGlobal(key) 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 if (global != null) return global
return when (mode) {
InputMode.ROUTER -> resolveRouterMode(key, inputText) // FILTER mode override: ↑↓ always navigates sessions list
InputMode.NAVIGATE -> resolveNavigateMode(key) if (inputMode == InputMode.FILTER) {
InputMode.FILTER, InputMode.STEER -> resolveTextMode(key, inputText) 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
}
} }
@@ -10,15 +10,8 @@ private val log = LoggerFactory.getLogger("com.correx.apps.tui.input")
@Suppress("CyclomaticComplexMethod", "ReturnCount") @Suppress("CyclomaticComplexMethod", "ReturnCount")
fun mapKey(event: TambouiKeyEvent): KeyEvent? { fun mapKey(event: TambouiKeyEvent): KeyEvent? {
log.debug("got event: {}", event) log.debug("got event: {}", event)
if (event.isCtrlC) return KeyEvent.Quit
if (event.hasAlt()) { if (event.hasAlt()) {
return when { return null
event.isChar('h') -> KeyEvent.ToggleApprovalOverlay.also {
log.debug("got event for toggle approval")
}
else -> null
}
} }
if (event.hasCtrl()) { if (event.hasCtrl()) {
return when { return when {
@@ -27,10 +20,9 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
event.isChar('c') -> KeyEvent.Cancel event.isChar('c') -> KeyEvent.Cancel
event.isChar('a') -> KeyEvent.Approve event.isChar('a') -> KeyEvent.Approve
event.isChar('r') -> KeyEvent.Reject event.isChar('r') -> KeyEvent.Reject
event.isChar('s') -> KeyEvent.Steer event.isChar('l') -> KeyEvent.ReturnToSessionList
event.isChar('e') -> KeyEvent.ToggleEventStrip
event.isChar('h') -> KeyEvent.ShowPendingApproval
event.isChar('e') -> KeyEvent.ToggleEventOverlay
else -> null else -> null
} }
} }
@@ -40,7 +32,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
KeyCode.ENTER -> KeyEvent.Enter KeyCode.ENTER -> KeyEvent.Enter
KeyCode.ESCAPE -> KeyEvent.Escape KeyCode.ESCAPE -> KeyEvent.Escape
KeyCode.BACKSPACE -> KeyEvent.Backspace KeyCode.BACKSPACE -> KeyEvent.Backspace
KeyCode.TAB -> KeyEvent.Filter KeyCode.TAB -> KeyEvent.Tab
KeyCode.CHAR -> { KeyCode.CHAR -> {
val ch = event.character() val ch = event.character()
if (ch.isISOControl()) null else KeyEvent.CharInput(ch) if (ch.isISOControl()) null else KeyEvent.CharInput(ch)
@@ -16,75 +16,75 @@ object InputReducer {
): Pair<TuiState, List<Effect>> = when (action) { ): Pair<TuiState, List<Effect>> = when (action) {
is Action.CycleMode -> state.copy( is Action.CycleMode -> state.copy(
inputMode = when (state.inputMode) { inputMode = when (state.inputMode) {
InputMode.ROUTER -> InputMode.NAVIGATE InputMode.ROUTER -> InputMode.FILTER
InputMode.NAVIGATE -> InputMode.FILTER
InputMode.FILTER -> InputMode.ROUTER InputMode.FILTER -> InputMode.ROUTER
InputMode.STEER -> InputMode.ROUTER
}, },
) to emptyList()
is Action.OpenNewSessionPrompt -> state.copy(
inputMode = InputMode.ROUTER,
inputBuffer = "", inputBuffer = "",
) to emptyList() ) to emptyList()
is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() is Action.AppendChar -> state.copy(
is Action.OpenSteeringPrompt -> { 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 active = state.selectedPendingApproval()
val steeringNote = state.inputBuffer.ifBlank { null }
if (active != null) { if (active != null) {
state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() val selectedId = state.sessions.selectedId
} else { val clearedSessions = state.sessions.copy(
state to emptyList() sessions = state.sessions.sessions.map { s ->
} if (s.id == selectedId) s.copy(pendingApproval = null) else s
} },
)
is Action.EnterSteer -> state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() state.copy(
is Action.OpenFilter -> state.copy(inputMode = InputMode.FILTER, inputBuffer = "") to emptyList() pendingDecision = null,
is Action.AppendChar -> state.copy(inputBuffer = state.inputBuffer + action.ch) to emptyList() approvalDismissed = false,
is Action.Backspace -> state.copy(inputBuffer = state.inputBuffer.dropLast(1)) to emptyList() inputBuffer = "",
is Action.CancelInput -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() sessions = clearedSessions,
is Action.SubmitInput -> when (state.inputMode) { ) to listOf(
InputMode.ROUTER -> { Effect.SendWs(
val text = state.inputBuffer.trim() ClientMessage.ApprovalResponse(
if (text.isNotBlank()) { requestId = ApprovalRequestId(active.requestId),
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to listOf( decision = decision,
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)), steeringNote = steeringNote,
)
} 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,
),
), ),
) ),
} 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() else -> state to emptyList()
} }
} }
@@ -1,8 +1,12 @@
package com.correx.apps.tui.reducer package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action 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.TuiState
import com.correx.apps.tui.state.displayState
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(RootReducer::class.java.name) private val log = LoggerFactory.getLogger(RootReducer::class.java.name)
@@ -36,6 +40,7 @@ object RootReducer {
return current to allEffects return current to allEffects
} }
@Suppress("CyclomaticComplexMethod")
private fun reduceThroughSubReducers( private fun reduceThroughSubReducers(
state: TuiState, state: TuiState,
action: Action, action: Action,
@@ -45,41 +50,150 @@ object RootReducer {
// Effect.Quit appended LAST so all cleanup effects from sub-reducers dispatch first. // 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. // 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. // 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 prevInputMode = state.inputMode
val prevInputBuffer = state.inputBuffer val prevInputBuffer = state.inputBuffer
val prevSelectedId = state.sessions.selectedId
log.debug("input state before reducers={}", state.input) log.debug("input state before reducers={}", state.input)
val (afterInput, ie) = InputReducer.reduce(state, action) val (afterInput, ie) = InputReducer.reduce(state, action)
log.debug("sessions state before reducers={}", state.sessions) 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) log.debug("connection state before reducers={}", state.connection)
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action) val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
log.debug("provider state before reducers={}", state.provider) log.debug("provider state before reducers={}", state.provider)
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action) val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
val selectedPending = sessions.sessions.firstOrNull { it.id == sessions.selectedId }?.pendingApproval // ── Cross-reducer state weaving ──
val prevSelectedPending = afterInput.sessions.sessions.firstOrNull { it.id == afterInput.sessions.selectedId }?.pendingApproval // After all sub-reducers have run, weave fields that span multiple reducer boundaries.
val approvalJustArrived = selectedPending != null && prevSelectedPending == null // 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( val withSubReducers = afterInput.copy(
sessions = sessions, sessions = sessions,
connection = connection, connection = connection,
provider = provider, 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( // 2. backgroundUpdateCount reset: on any selectedId change (ARCH-BADGE-2).
eventOverlayVisible = !withSubReducers.eventOverlayVisible, val withBgReset = if (sessions.selectedId != prevSelectedId) {
approvalOverlayVisible = false, withSubReducers.copy(sessions = sessions.copy(backgroundUpdateCount = 0))
) } else {
withSubReducers
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 combined = ie + se + ce + pe
val withTerminal = if (action is Action.Quit) combined + Effect.Quit else combined val withTerminal = if (action is Action.Quit) combined + Effect.Quit else combined
return final to withTerminal return final to withTerminal
@@ -1,15 +1,16 @@
package com.correx.apps.tui.reducer 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.ClientMessage
import com.correx.apps.server.protocol.PauseReason import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ApprovalInfo 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.InputMode
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.ToolDisplayStatus 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.TuiEventEntry
import com.correx.apps.tui.state.TuiToolRecord import com.correx.apps.tui.state.TuiToolRecord
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
@@ -26,27 +27,44 @@ object SessionsReducer {
fun reduce( fun reduce(
sessions: SessionsState, sessions: SessionsState,
displayState: DisplayState,
inputMode: InputMode, inputMode: InputMode,
inputText: String, inputText: String,
action: Action, action: Action,
clock: () -> Long = System::currentTimeMillis, clock: () -> Long = System::currentTimeMillis,
): Pair<SessionsState, List<Effect>> = when (action) { ): Pair<SessionsState, List<Effect>> = when (action) {
is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) { is Action.NavigateUp -> when {
navigateUp(sessions) to emptyList() inputMode == InputMode.FILTER -> navigateUp(sessions) to emptyList()
} else { displayState == DisplayState.IDLE -> navigateUp(sessions) to emptyList()
sessions to emptyList() displayState == DisplayState.IN_SESSION -> sessions to emptyList()
else -> sessions to emptyList()
} }
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) { is Action.NavigateDown -> when {
navigateDown(sessions) to emptyList() inputMode == InputMode.FILTER -> navigateDown(sessions) to emptyList()
} else { displayState == DisplayState.IDLE -> navigateDown(sessions) to emptyList()
sessions to emptyList() displayState == DisplayState.IN_SESSION -> sessions to emptyList()
else -> sessions to emptyList()
} }
is Action.SubmitInput -> if (inputMode == InputMode.FILTER) { is Action.SubmitInput -> when {
sessions.copy(filter = inputText) to emptyList() inputMode == InputMode.FILTER -> sessions.copy(filter = inputText) to emptyList()
} else { displayState == DisplayState.IDLE -> {
sessions to emptyList() 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) { 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) is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
else -> sessions to emptyList() else -> sessions to emptyList()
} }
private fun respondToSelectedApproval(
sessions: SessionsState,
decision: ApprovalDecision,
note: String?,
): Pair<SessionsState, List<Effect>> {
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<SessionSummary> = private fun filteredSessions(sessions: SessionsState): List<SessionSummary> =
if (sessions.filter.isBlank()) sessions.sessions if (sessions.filter.isBlank()) sessions.sessions
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) } else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
@@ -122,23 +114,91 @@ object SessionsReducer {
sessions: SessionsState, sessions: SessionsState,
msg: ServerMessage, msg: ServerMessage,
clock: () -> Long, clock: () -> Long,
): Pair<SessionsState, List<Effect>> = when (msg) { ): Pair<SessionsState, List<Effect>> {
is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions) val (result, effects) = when (msg) {
is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock) is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions)
is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock) is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock)
is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock) is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock)
is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions) is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock)
is ServerMessage.StageCompleted -> processStageCompletedMessage(msg, sessions) is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions)
is ServerMessage.StageFailed -> processStageFailedMessage(msg, sessions) is ServerMessage.StageCompleted -> processStageCompletedMessage(msg, sessions)
is ServerMessage.ToolStarted -> processToolStartedMessage(clock, sessions, msg) is ServerMessage.StageFailed -> processStageFailedMessage(msg, sessions)
is ServerMessage.InferenceCompleted -> processInferenceCompletedMessage(sessions, msg) is ServerMessage.ToolStarted -> processToolStartedMessage(clock, sessions, msg)
is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions) is ServerMessage.InferenceCompleted -> processInferenceCompletedMessage(sessions, msg)
is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions) is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions)
is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg) is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions)
is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg) is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg)
is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg) is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg)
else -> sessions to emptyList() is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg)
}.also { log.debug("processed server message: {}", 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<SessionsState, List<Effect>> {
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( private fun processSessionSnapshotMessage(
clock: () -> Long, clock: () -> Long,
@@ -359,19 +419,21 @@ object SessionsReducer {
sessions: SessionsState, sessions: SessionsState,
msg: ServerMessage.SessionFailed, msg: ServerMessage.SessionFailed,
clock: () -> Long, clock: () -> Long,
): Pair<SessionsState, List<Effect>> = touchSession( ): Pair<SessionsState, List<Effect>> {
sessions, val result = touchSession(sessions, msg.sessionId.value, "FAILED", clock)
msg.sessionId.value, val cleared = if (msg.sessionId.value == result.selectedId) result.copy(selectedId = null) else result
"FAILED", return cleared to emptyList()
clock, }
) to emptyList()
private fun processSessionCompletedMessage( private fun processSessionCompletedMessage(
sessions: SessionsState, sessions: SessionsState,
msg: ServerMessage.SessionCompleted, msg: ServerMessage.SessionCompleted,
clock: () -> Long, clock: () -> Long,
): Pair<SessionsState, List<Effect>> = ): Pair<SessionsState, List<Effect>> {
touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to emptyList() 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( private fun processSessionPausedMessage(
msg: ServerMessage.SessionPaused, msg: ServerMessage.SessionPaused,
@@ -395,6 +457,8 @@ object SessionsReducer {
clock: () -> Long, clock: () -> Long,
sessions: SessionsState, sessions: SessionsState,
): Pair<SessionsState, List<Effect>> { ): Pair<SessionsState, List<Effect>> {
// TODO(RF-3): If StageToolManifest created a placeholder, dedup/replace instead of
// unconditional append to avoid duplicate session entries.
val summary = SessionSummary( val summary = SessionSummary(
id = msg.sessionId.value, id = msg.sessionId.value,
status = "ACTIVE", status = "ACTIVE",
@@ -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
}
}
@@ -4,4 +4,5 @@ data class SessionsState(
val sessions: List<SessionSummary> = emptyList(), val sessions: List<SessionSummary> = emptyList(),
val selectedId: String? = null, val selectedId: String? = null,
val filter: String = "", val filter: String = "",
val backgroundUpdateCount: Int = 0,
) )
@@ -1,8 +1,9 @@
package com.correx.apps.tui.state package com.correx.apps.tui.state
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ServerMessage 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 } enum class ToolDisplayStatus { REQUESTED, STARTED, COMPLETED, FAILED, REJECTED }
@@ -28,8 +29,12 @@ data class TuiState(
val provider: ProviderState = ProviderState(), val provider: ProviderState = ProviderState(),
val inputMode: InputMode = InputMode.ROUTER, val inputMode: InputMode = InputMode.ROUTER,
val inputBuffer: String = "", val inputBuffer: String = "",
val approvalOverlayVisible: Boolean = false, val eventStripVisible: Boolean = true,
val eventOverlayVisible: Boolean = false, val inputHistory: Map<String, List<String>> = emptyMap(),
val inputHistoryIndex: Int = -1,
val savedInputBuffer: String = "",
val pendingDecision: ApprovalDecision? = null,
val approvalDismissed: Boolean = false,
val currentModel: String? = null, val currentModel: String? = null,
val providerType: ProviderType = ProviderType.LOCAL, val providerType: ProviderType = ProviderType.LOCAL,
val routerConnected: Boolean = false, val routerConnected: Boolean = false,
@@ -51,6 +56,11 @@ data class TuiState(
val cursors: Map<String, Long> = emptyMap(), val cursors: Map<String, Long> = emptyMap(),
) )
data class ToolManifestEntry(
val name: String,
val tier: Int,
)
data class SessionSummary( data class SessionSummary(
val id: String, val id: String,
val status: String, val status: String,
@@ -63,6 +73,7 @@ data class SessionSummary(
val lastOutput: String? = null, val lastOutput: String? = null,
val lastResponseText: String? = null, val lastResponseText: String? = null,
val tools: List<TuiToolRecord> = emptyList(), val tools: List<TuiToolRecord> = emptyList(),
val toolsByStage: Map<String, List<ToolManifestEntry>> = emptyMap(),
val recentEvents: List<TuiEventEntry> = emptyList(), val recentEvents: List<TuiEventEntry> = emptyList(),
val pendingApproval: ApprovalInfo? = null, val pendingApproval: ApprovalInfo? = null,
) )
@@ -1,117 +1,147 @@
package com.correx.apps.tui.input package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
class KeyResolverTest { class KeyResolverTest {
@Test
fun `n in ROUTER mode resolves to OpenNewSessionPrompt`() {
val result = KeyResolver.resolve(KeyEvent.NewSession, InputMode.ROUTER, "")
assertEquals(Action.OpenNewSessionPrompt, result)
}
@Test private fun resolve(
fun `command letters in text input mode resolve to null`() { key: KeyEvent,
val result = KeyResolver.resolve(KeyEvent.NewSession, InputMode.FILTER, "") displayState: DisplayState = DisplayState.IDLE,
assertNull(result) inputMode: InputMode = InputMode.ROUTER,
} hasPendingApproval: Boolean = false,
inputText: String = "",
) = KeyResolver.resolve(key, displayState, inputMode, hasPendingApproval, inputText)
@Test // ── Global actions (work regardless of display state / input mode) ──
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)
}
@Test @Test
fun `Quit in ROUTER mode resolves to Quit`() { fun `Quit in ROUTER mode resolves to Quit`() {
val result = KeyResolver.resolve(KeyEvent.Quit, InputMode.ROUTER, "") assertEquals(Action.Quit, resolve(KeyEvent.Quit))
assertEquals(Action.Quit, result)
} }
@Test @Test
fun `Quit in text input mode resolves to null`() { fun `Quit in FILTER mode resolves to Quit`() {
val result = KeyResolver.resolve(KeyEvent.Quit, InputMode.FILTER, "") assertEquals(Action.Quit, resolve(KeyEvent.Quit, inputMode = InputMode.FILTER))
assertNull(result)
} }
@Test @Test
fun `Cancel in ROUTER mode resolves to CancelSelectedSession`() { fun `Approve resolves globally to ApproveActive`() {
val result = KeyResolver.resolve(KeyEvent.Cancel, InputMode.ROUTER, "") assertEquals(Action.ApproveActive, resolve(KeyEvent.Approve))
assertEquals(Action.CancelSelectedSession, result)
} }
@Test @Test
fun `Approve in ROUTER mode resolves to ApproveActive`() { fun `Reject resolves globally to RejectActive`() {
val result = KeyResolver.resolve(KeyEvent.Approve, InputMode.ROUTER, "") assertEquals(Action.RejectActive, resolve(KeyEvent.Reject))
assertEquals(Action.ApproveActive, result) }
// ── FILTER mode ──
@Test
fun `NavUp in FILTER mode resolves to NavigateUp`() {
assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, inputMode = InputMode.FILTER))
} }
@Test @Test
fun `Reject in ROUTER mode resolves to RejectActive`() { fun `NavDown in FILTER mode resolves to NavigateDown`() {
val result = KeyResolver.resolve(KeyEvent.Reject, InputMode.ROUTER, "") assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, inputMode = InputMode.FILTER))
assertEquals(Action.RejectActive, result)
} }
@Test @Test
fun `Steer in ROUTER mode resolves to OpenSteeringPrompt`() { fun `Enter in FILTER mode on blank input resolves to null`() {
val result = KeyResolver.resolve(KeyEvent.Steer, InputMode.ROUTER, "") assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER))
assertEquals(Action.OpenSteeringPrompt, result)
} }
@Test @Test
fun `NavUp in ROUTER mode resolves to NavigateUp`() { fun `Enter in FILTER mode on non-blank input resolves to SubmitInput`() {
val result = KeyResolver.resolve(KeyEvent.NavUp, InputMode.ROUTER, "") assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = "test"))
assertEquals(Action.NavigateUp, result)
} }
@Test @Test
fun `NavDown in ROUTER mode resolves to NavigateDown`() { fun `Enter in FILTER mode on whitespace-only input resolves to null`() {
val result = KeyResolver.resolve(KeyEvent.NavDown, InputMode.ROUTER, "") assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = " "))
assertEquals(Action.NavigateDown, result)
} }
@Test @Test
fun `Filter in ROUTER mode cycles to NAVIGATE`() { fun `Escape in FILTER mode resolves to CancelInput`() {
val result = KeyResolver.resolve(KeyEvent.Filter, InputMode.ROUTER, "") assertEquals(Action.CancelInput, resolve(KeyEvent.Escape, inputMode = InputMode.FILTER))
assertEquals(Action.CycleMode, result)
} }
@Test @Test
fun `Enter on whitespace-only input resolves to null`() { fun `Backspace in FILTER mode resolves to Backspace`() {
val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.FILTER, " ") assertEquals(Action.Backspace, resolve(KeyEvent.Backspace, inputMode = InputMode.FILTER))
assertNull(result) }
@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 @Test
fun `CharInput in ROUTER mode resolves to AppendChar`() { fun `CharInput in ROUTER mode resolves to AppendChar`() {
val result = KeyResolver.resolve(KeyEvent.CharInput('x'), InputMode.ROUTER, "") assertEquals(Action.AppendChar('x'), resolve(KeyEvent.CharInput('x')))
assertEquals(Action.AppendChar('x'), result) }
@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))
} }
} }
@@ -10,26 +10,11 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
class TambouiKeyMapperTest { class TambouiKeyMapperTest {
// --- Ctrl-C always → Quit --- // --- Ctrl-C always → Cancel ---
@Test @Test
fun `ctrl-c maps to Quit in InputMode ROUTER`() { fun `ctrl-c maps to Cancel`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code))) assertEquals(KeyEvent.Cancel, 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)))
} }
// --- Ctrl+key → keybind events --- // --- Ctrl+key → keybind events ---
@@ -55,18 +40,13 @@ class TambouiKeyMapperTest {
} }
@Test @Test
fun `ctrl-s maps to Steer`() { fun `ctrl-h maps to ShowPendingApproval`() {
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code))) assertEquals(KeyEvent.ShowPendingApproval, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code)))
} }
@Test @Test
fun `alt-h maps to ToggleApprovalOverlay`() { fun `ctrl-e maps to ToggleEventStrip`() {
assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'h'.code))) assertEquals(KeyEvent.ToggleEventStrip, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code)))
}
@Test
fun `ctrl-e maps to ToggleEventOverlay`() {
assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code)))
} }
@Test @Test
@@ -109,12 +89,7 @@ class TambouiKeyMapperTest {
} }
@Test @Test
fun `bare q maps to CharInput in STEER`() { fun `bare q maps to CharInput`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
}
@Test
fun `bare q maps to CharInput in NAVIGATE`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'))) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
} }
@@ -146,13 +121,8 @@ class TambouiKeyMapperTest {
} }
@Test @Test
fun `TAB maps to Filter`() { fun `TAB maps to Tab`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB))) assertEquals(KeyEvent.Tab, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
}
@Test
fun `TAB maps to Filter in FILTER mode`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
} }
// --- ISO control chars → null --- // --- ISO control chars → null ---
@@ -1,12 +1,7 @@
package com.correx.apps.tui.reducer 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.input.Action
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.InputMode 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.TuiState
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@@ -14,30 +9,6 @@ import org.junit.jupiter.api.Test
class InputReducerTest { 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( private fun reduce(
state: TuiState = TuiState(), state: TuiState = TuiState(),
action: Action, action: Action,
@@ -51,23 +22,6 @@ class InputReducerTest {
assertTrue(effects.isEmpty()) 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 @Test
fun `AppendChar appends character to buffer`() { fun `AppendChar appends character to buffer`() {
val (state, effects) = reduce( val (state, effects) = reduce(
@@ -109,64 +63,32 @@ class InputReducerTest {
} }
@Test @Test
fun `SubmitInput in ROUTER mode emits StartSession effect`() { fun `SubmitInput clears input buffer with no effects`() {
val (state, effects) = reduce( val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow"), state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow"),
action = Action.SubmitInput, action = Action.SubmitInput,
) )
assertEquals(InputMode.ROUTER, state.inputMode) assertEquals("", state.inputBuffer)
assertEquals(1, effects.size) assertTrue(effects.isEmpty())
val effect = effects[0] as Effect.SendWs
val msg = effect.message as ClientMessage.StartSession
assertEquals("my-workflow", msg.workflowId)
} }
@Test @Test
fun `SubmitInput in ROUTER mode with blank text emits no effect`() { fun `SubmitInput with blank text clears input buffer`() {
val (state, effects) = reduce( val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = " "), state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = " "),
action = Action.SubmitInput, action = Action.SubmitInput,
) )
assertEquals(InputMode.ROUTER, state.inputMode) assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty()) assertTrue(effects.isEmpty())
} }
@Test @Test
fun `SubmitInput in STEER mode with active approval emits ApprovalResponse APPROVE with steering note`() { fun `SubmitInput in FILTER mode clears buffer with no effects`() {
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`() {
val (state, effects) = reduce( val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.FILTER, inputBuffer = "myfilter"), state = TuiState(inputMode = InputMode.FILTER, inputBuffer = "myfilter"),
action = Action.SubmitInput, action = Action.SubmitInput,
) )
assertEquals(InputMode.ROUTER, state.inputMode) assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty()) assertTrue(effects.isEmpty())
} }
} }
@@ -5,10 +5,12 @@ import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ConnectionState 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.InputMode
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import com.correx.core.approvals.Tier import com.correx.core.approvals.Tier
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
@@ -53,29 +55,29 @@ class RootReducerTest {
} }
@Test @Test
fun `NavigateUp with sessions adjusts selected id`() { fun `NavigateDown in IDLE with no selection selects first session`() {
val state0 = TuiState(snapshotPhase = false) val state = TuiState(
val startMsg = com.correx.apps.server.protocol.ServerMessage.SessionStarted( snapshotPhase = false,
sessionId = com.correx.core.events.types.SessionId("s1"), sessions = SessionsState(
workflowId = "wf", sessions = listOf(session("s1"), session("s2")),
sequence = 1L, selectedId = null,
sessionSequence = 1L, ),
) )
val (state1, _) = RootReducer.reduce(state0, Action.ServerEventReceived(startMsg), fixedClock) val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock)
val startMsg2 = com.correx.apps.server.protocol.ServerMessage.SessionStarted( assertEquals("s1", result.sessions.selectedId)
sessionId = com.correx.core.events.types.SessionId("s2"), }
workflowId = "wf",
sequence = 2L, @Test
sessionSequence = 1L, 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) val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
// selected is s1 (first); navigating down should move to s2 assertEquals("c", result.sessions.selectedId)
val (state3, _) = RootReducer.reduce(
state2.copy(inputMode = InputMode.NAVIGATE),
Action.NavigateDown,
fixedClock,
)
assertEquals("s2", state3.sessions.selectedId)
} }
@Test @Test
@@ -138,6 +140,151 @@ class RootReducerTest {
assertTrue(effects.isEmpty()) 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 @Test
fun `AppendChar followed by Backspace returns to original buffer`() { fun `AppendChar followed by Backspace returns to original buffer`() {
val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab") 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) private fun session(id: String) = SessionSummary(id = id, status = "ACTIVE", workflowId = "wf", lastEventAt = 0L)
@Test @Test
fun `ApprovalRequired on selected session auto-opens approvalOverlayVisible`() { fun `ApprovalRequired on selected session transitions display state to APPROVAL`() {
val initial = TuiState( val initial = TuiState(
snapshotPhase = false, snapshotPhase = false,
sessions = SessionsState(sessions = listOf(session("s1")), selectedId = "s1"), sessions = SessionsState(sessions = listOf(session("s1")), selectedId = "s1"),
@@ -169,12 +316,12 @@ class RootReducerTest {
val (state, _) = RootReducer.reduce( val (state, _) = RootReducer.reduce(
initial, Action.ServerEventReceived(approvalRequiredMsg("s1")), fixedClock, initial, Action.ServerEventReceived(approvalRequiredMsg("s1")), fixedClock,
) )
assertEquals(true, state.approvalOverlayVisible) assertEquals(DisplayState.APPROVAL, state.displayState)
assertEquals("r1", state.sessions.sessions[0].pendingApproval?.requestId) assertEquals("r1", state.sessions.sessions[0].pendingApproval?.requestId)
} }
@Test @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( val initial = TuiState(
snapshotPhase = false, snapshotPhase = false,
sessions = SessionsState( sessions = SessionsState(
@@ -185,7 +332,7 @@ class RootReducerTest {
val (state, _) = RootReducer.reduce( val (state, _) = RootReducer.reduce(
initial, Action.ServerEventReceived(approvalRequiredMsg("s2")), fixedClock, 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(null, state.sessions.sessions.find { it.id == "s1" }?.pendingApproval)
assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId) assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId)
} }
@@ -8,6 +8,7 @@ import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.SessionStateDto import com.correx.apps.server.protocol.SessionStateDto
import com.correx.apps.tui.input.Action 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.InputMode
import com.correx.apps.tui.state.ApprovalInfo import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionSummary
@@ -35,15 +36,16 @@ class SessionsReducerTest {
private fun reduce( private fun reduce(
sessions: SessionsState = SessionsState(), sessions: SessionsState = SessionsState(),
displayState: DisplayState = DisplayState.IDLE,
inputMode: InputMode = InputMode.ROUTER, inputMode: InputMode = InputMode.ROUTER,
inputText: String = "", inputText: String = "",
action: Action, action: Action,
) = SessionsReducer.reduce(sessions, inputMode, inputText, action, fixedClock) ) = SessionsReducer.reduce(sessions, displayState, inputMode, inputText, action, fixedClock)
@Test @Test
fun `NavigateUp wraps from top to bottom`() { fun `NavigateUp wraps from top to bottom`() {
val s = SessionsState(sessions = listOf(session("a"), session("b"), session("c")), selectedId = "a") 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) assertEquals("c", state.selectedId)
assertTrue(effects.isEmpty()) assertTrue(effects.isEmpty())
} }
@@ -51,14 +53,14 @@ class SessionsReducerTest {
@Test @Test
fun `NavigateDown advances selection`() { fun `NavigateDown advances selection`() {
val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "a") 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) assertEquals("b", state.selectedId)
} }
@Test @Test
fun `NavigateDown wraps from bottom to top`() { fun `NavigateDown wraps from bottom to top`() {
val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "b") 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) assertEquals("a", state.selectedId)
} }
@@ -318,32 +320,25 @@ class SessionsReducerTest {
) )
@Test @Test
fun `ApproveActive emits ApprovalResponse APPROVE and clears pendingApproval`() { fun `ApproveActive is no-op in SessionsReducer (handled by RootReducer pendingDecision)`() {
val s = SessionsState( val s = SessionsState(
sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())), sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())),
selectedId = "s1", selectedId = "s1",
) )
val (state, effects) = reduce(sessions = s, action = Action.ApproveActive) val (state, effects) = reduce(sessions = s, action = Action.ApproveActive)
assertEquals(null, state.sessions[0].pendingApproval) assertEquals(s, state)
assertEquals(1, effects.size) assertTrue(effects.isEmpty())
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)
} }
@Test @Test
fun `RejectActive emits ApprovalResponse REJECT and clears pendingApproval`() { fun `RejectActive is no-op in SessionsReducer (handled by RootReducer pendingDecision)`() {
val s = SessionsState( val s = SessionsState(
sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())), sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())),
selectedId = "s1", selectedId = "s1",
) )
val (state, effects) = reduce(sessions = s, action = Action.RejectActive) val (state, effects) = reduce(sessions = s, action = Action.RejectActive)
assertEquals(null, state.sessions[0].pendingApproval) assertEquals(s, state)
assertEquals(1, effects.size) assertTrue(effects.isEmpty())
val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.REJECT, msg.decision)
assertEquals("req-1", msg.requestId.value)
} }
@Test @Test