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

Restructure the TUI around a DisplayState enum (IDLE, IN_SESSION, APPROVAL) replacing
the previous InputMode-based navigation (NAVIGATE, STEER removed). Key changes:

- **DisplayState model**: New `DisplayState` enum governs per-mode key resolution and UI
  rendering. Removes `ROUTER`/`NAVIGATE`/`STEER`/`FILTER` InputMode complexity.
- **Input history navigation**: Up/Down arrows in IN_SESSION cycle through per-session
  input history (ARCH-HISTORY-2/3). `savedInputBuffer` preserves in-flight text.
- **Input history filter**: `Tab` toggles between ROUTER and FILTER modes; history nav
  disabled while filtering.
- **Ctrl-C → Cancel**: Rebind Ctrl-C from Quit to Cancel across all states.
- **Approval flow**: `ApproveActive`/`RejectActive` moved from SessionsReducer to
  RootReducer via `pendingDecision`/`SubmitApprovalDecision`. Approval overlay
  replaced by `ApprovalSurface` component.
- **Event history strip**: Replaces old overlay with a persistent `EventHistoryStrip`
  component, toggled via Ctrl-E.
- **Background update badge**: New `backgroundUpdateCount` on SessionsState tracks
  events arriving for non-selected sessions (ARCH-BADGE-1).
- **StageToolManifest**: New `ServerMessage.StageToolManifest` and `ToolManifestEntry`
  model for pre-declared tool shapes per stage (RF-3).
- **Cleanup**: Removed `ActiveSession`, `ApprovalPanel`, `ToolsPanel` components.
  Removed `InputMode.NAVIGATE`, `InputMode.STEER`, approval overlay state fields.
  Various reducer simplifications (no more overlaid effects for approval responses).
This commit is contained in:
2026-05-26 01:46:14 +04:00
parent 2e3b8e599c
commit f6ef028883
32 changed files with 1180 additions and 701 deletions
@@ -131,6 +131,7 @@ fun main() {
routerFacade = routerFacade,
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,
),
)))
}
}
@@ -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 {