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,
|
||||
orchestrationRepository = repositories.orchestrationRepository,
|
||||
approvalRepository = repositories.approvalRepository,
|
||||
toolRegistry = toolRegistry,
|
||||
approvalConfig = correxConfig.approval,
|
||||
)
|
||||
module.start()
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -32,6 +33,7 @@ class ServerModule(
|
||||
val routerFacade: RouterFacade,
|
||||
val orchestrationRepository: OrchestrationRepository,
|
||||
val approvalRepository: DefaultApprovalRepository,
|
||||
val toolRegistry: ToolRegistry,
|
||||
approvalConfig: ApprovalConfig = ApprovalConfig(),
|
||||
// Long-lived scope owned by the module — backs the ApprovalCoordinator timers
|
||||
// and event-store subscription. SupervisorJob so one failure doesn't kill the
|
||||
|
||||
@@ -3,18 +3,24 @@ package com.correx.apps.server.bridge
|
||||
import com.correx.apps.server.protocol.ApprovalDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.SessionStateDto
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
|
||||
class SessionEventBridge(
|
||||
private val eventStore: EventStore,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val orchestrationRepository: OrchestrationRepository,
|
||||
private val approvalRepository: DefaultApprovalRepository,
|
||||
private val workflowRegistry: WorkflowRegistry,
|
||||
private val toolRegistry: ToolRegistry,
|
||||
private val send: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
suspend fun replaySnapshot() {
|
||||
@@ -46,6 +52,24 @@ class SessionEventBridge(
|
||||
lastSequence = lastGlobal,
|
||||
lastSessionSequence = lastSession,
|
||||
))
|
||||
|
||||
// Emit StageToolManifest for this session (ARCH-TOOL-1)
|
||||
val graph = workflowRegistry.find(orchState.workflowId)
|
||||
if (graph != null) {
|
||||
val stages = graph.stages.map { (stageId, stageConfig) ->
|
||||
val tools = stageConfig.allowedTools.mapNotNull { toolName ->
|
||||
toolRegistry.resolve(toolName)?.let { tool ->
|
||||
ToolDecl(name = tool.name, tier = tool.tier.level)
|
||||
}
|
||||
}
|
||||
StageToolDecl(stageId = stageId.value, tools = tools)
|
||||
}
|
||||
send(ServerMessage.StageToolManifest(
|
||||
sessionId = sessionId,
|
||||
workflowId = orchState.workflowId,
|
||||
stages = stages,
|
||||
))
|
||||
}
|
||||
}
|
||||
send(ServerMessage.SnapshotComplete)
|
||||
}
|
||||
|
||||
@@ -40,3 +40,15 @@ enum class PauseReason {
|
||||
APPROVAL_PENDING,
|
||||
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 --
|
||||
|
||||
@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
|
||||
@SerialName("provider.status_changed")
|
||||
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.ProviderHealthDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
@@ -47,9 +49,11 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
val bridge = SessionEventBridge(
|
||||
eventStore = module.eventStore,
|
||||
artifactStore = module.artifactStore,
|
||||
send = sendFrame,
|
||||
orchestrationRepository = module.orchestrationRepository,
|
||||
approvalRepository = module.approvalRepository,
|
||||
workflowRegistry = module.workflowRegistry,
|
||||
toolRegistry = module.toolRegistry,
|
||||
send = sendFrame,
|
||||
)
|
||||
val mapper = DomainEventMapper(module.artifactStore)
|
||||
|
||||
@@ -201,6 +205,23 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
sessionSequence = 0L,
|
||||
)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
|
||||
|
||||
// Emit StageToolManifest for the newly created session (ARCH-TOOL-1)
|
||||
val stages = graph.stages.map { (stageId, stageConfig) ->
|
||||
val tools = stageConfig.allowedTools.mapNotNull { toolName ->
|
||||
module.toolRegistry.resolve(toolName)?.let { tool ->
|
||||
ToolDecl(name = tool.name, tier = tool.tier.level)
|
||||
}
|
||||
}
|
||||
StageToolDecl(stageId = stageId.value, tools = tools)
|
||||
}
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(
|
||||
ServerMessage.StageToolManifest(
|
||||
sessionId = sessionId,
|
||||
workflowId = msg.workflowId,
|
||||
stages = stages,
|
||||
),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-4
@@ -1,6 +1,7 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
@@ -21,6 +22,8 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -98,6 +101,16 @@ class SessionEventBridgeTest {
|
||||
),
|
||||
)
|
||||
|
||||
private val noopWorkflowRegistry = object : WorkflowRegistry {
|
||||
override fun listAll(): List<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
|
||||
fun `replaySnapshot sends mapped messages for all events`() = runTest {
|
||||
val events = listOf(
|
||||
@@ -112,6 +125,8 @@ class SessionEventBridgeTest {
|
||||
noopArtifactStore,
|
||||
orchRepo,
|
||||
approvalRepository,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
@@ -138,6 +153,8 @@ class SessionEventBridgeTest {
|
||||
noopArtifactStore,
|
||||
orchRepo,
|
||||
approvalRepository,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
@@ -162,6 +179,8 @@ class SessionEventBridgeTest {
|
||||
noopArtifactStore,
|
||||
activeOrchestrationRepository(),
|
||||
approvalRepository,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.streamLive(sessionId)
|
||||
@@ -181,7 +200,7 @@ class SessionEventBridgeTest {
|
||||
fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest {
|
||||
val store = fakeEventStore(allEventsList = emptyList())
|
||||
val sent = mutableListOf<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()
|
||||
assertEquals(1, sent.size)
|
||||
assertEquals(ServerMessage.SnapshotComplete, sent[0])
|
||||
@@ -192,7 +211,7 @@ class SessionEventBridgeTest {
|
||||
val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<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()
|
||||
assertEquals(ServerMessage.SnapshotComplete, sent.last())
|
||||
}
|
||||
@@ -207,7 +226,7 @@ class SessionEventBridgeTest {
|
||||
override suspend fun lastGlobalSequence(): Long = 5L
|
||||
}
|
||||
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()
|
||||
val snapshot = sent[0] as ServerMessage.SessionSnapshot
|
||||
assertEquals(5L, snapshot.lastSequence)
|
||||
@@ -221,7 +240,7 @@ class SessionEventBridgeTest {
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<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()
|
||||
val snapshot = sent[0] as ServerMessage.SessionSnapshot
|
||||
assertEquals(3L, snapshot.lastSessionSequence)
|
||||
@@ -240,6 +259,8 @@ class SessionEventBridgeTest {
|
||||
noopArtifactStore,
|
||||
activeOrchestrationRepository(),
|
||||
approvalRepository,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.correx.apps.server.ws
|
||||
import com.correx.apps.server.bridge.DomainEventMapper
|
||||
import com.correx.apps.server.bridge.SessionEventBridge
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
@@ -21,6 +22,8 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
@@ -103,6 +106,16 @@ class GlobalStreamHandlerTest {
|
||||
},
|
||||
)
|
||||
|
||||
private val noopWorkflowRegistry = object : WorkflowRegistry {
|
||||
override fun listAll(): List<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 =
|
||||
DefaultApprovalRepository(
|
||||
DefaultEventReplayer(
|
||||
@@ -121,7 +134,7 @@ class GlobalStreamHandlerTest {
|
||||
): Pair<SessionEventBridge, Channel<ServerMessage>> {
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, approvals) { received.send(it) }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, approvals, noopWorkflowRegistry, noopToolRegistry) { received.send(it) }
|
||||
return bridge to received
|
||||
}
|
||||
|
||||
@@ -168,7 +181,7 @@ class GlobalStreamHandlerTest {
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L)
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) {
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) {
|
||||
received.send(it)
|
||||
}
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
@@ -211,7 +224,7 @@ class GlobalStreamHandlerTest {
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) {
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) {
|
||||
received.send(it)
|
||||
}
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
@@ -260,7 +273,7 @@ class GlobalStreamHandlerTest {
|
||||
}
|
||||
}
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { }
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
|
||||
val job = launch {
|
||||
@@ -278,7 +291,7 @@ class GlobalStreamHandlerTest {
|
||||
val liveFlow = MutableSharedFlow<StoredEvent>()
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { }
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
|
||||
val job = launch {
|
||||
|
||||
Reference in New Issue
Block a user