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:
@@ -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,
|
||||||
|
),
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-4
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user