From 1017bfffefc5edb8edda2042937c88ab26771a51 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 2 Jun 2026 23:38:20 +0400 Subject: [PATCH] feat(tui): event-derived ordering + retire Kotlin TUI Rebuild the Go TUI transcript from the event stream instead of optimistic local echoes. ChatTurnEvent now maps to ServerMessage.ChatTurn (chat.turn); the TUI auto-vivifies sessions and renders user+router turns from events. SessionStarted is de-special-cased into SessionAnnounced (still carries workflowId), dropping the router.response shadow and optimistic echoes. Delete the unused Kotlin TUI (apps/tui) and add a kotlinx<->Go golden-fixture test to lock the wire protocol. Gitignore server runtime logs. --- .gitignore | 3 + .../apps/server/bridge/DomainEventMapper.kt | 14 +- .../apps/server/protocol/ServerMessage.kt | 24 +- .../apps/server/ws/GlobalStreamHandler.kt | 25 +- .../server/bridge/DomainEventMapperTest.kt | 24 +- .../ServerMessageSerializationTest.kt | 29 +- .../apps/server/ws/GlobalStreamHandlerTest.kt | 6 +- apps/tui-go/internal/app/model.go | 13 + apps/tui-go/internal/app/server.go | 53 +- apps/tui-go/internal/app/update.go | 13 +- apps/tui-go/internal/protocol/golden_test.go | 86 +++ apps/tui-go/internal/protocol/protocol.go | 9 +- apps/tui/build.gradle | 73 -- .../kotlin/com/correx/apps/tui/KeyEvent.kt | 25 - .../main/kotlin/com/correx/apps/tui/TuiApp.kt | 248 ------- .../apps/tui/components/ApprovalSurface.kt | 115 --- .../apps/tui/components/EventHistoryStrip.kt | 68 -- .../apps/tui/components/EventStreamPanel.kt | 120 ---- .../correx/apps/tui/components/FooterBar.kt | 63 -- .../correx/apps/tui/components/InputBar.kt | 135 ---- .../correx/apps/tui/components/RouterPanel.kt | 48 -- .../correx/apps/tui/components/SessionList.kt | 67 -- .../correx/apps/tui/components/StatusBar.kt | 123 ---- .../com/correx/apps/tui/components/Theme.kt | 105 --- .../apps/tui/components/WelcomePanel.kt | 104 --- .../apps/tui/components/WorkflowListPanel.kt | 47 -- .../com/correx/apps/tui/input/Action.kt | 33 - .../com/correx/apps/tui/input/KeyResolver.kt | 87 --- .../correx/apps/tui/input/TambouiKeyMapper.kt | 48 -- .../apps/tui/reducer/ConnectionReducer.kt | 21 - .../com/correx/apps/tui/reducer/Effect.kt | 8 - .../apps/tui/reducer/EffectDispatcher.kt | 15 - .../correx/apps/tui/reducer/InputReducer.kt | 123 ---- .../apps/tui/reducer/ProviderReducer.kt | 21 - .../correx/apps/tui/reducer/RootReducer.kt | 294 -------- .../apps/tui/reducer/ServerMessageReducer.kt | 10 - .../apps/tui/reducer/SessionsReducer.kt | 661 ------------------ .../apps/tui/reducer/SnapshotPhaseReducer.kt | 89 --- .../correx/apps/tui/state/ConnectionState.kt | 8 - .../com/correx/apps/tui/state/DisplayState.kt | 16 - .../com/correx/apps/tui/state/InputState.kt | 6 - .../correx/apps/tui/state/ProviderState.kt | 6 - .../correx/apps/tui/state/SessionsState.kt | 15 - .../com/correx/apps/tui/state/TuiState.kt | 116 --- .../com/correx/apps/tui/ws/ConnectionEvent.kt | 7 - .../com/correx/apps/tui/ws/TuiWsClient.kt | 90 --- .../apps/tui/SingleConsumerContractTest.kt | 29 - .../correx/apps/tui/input/KeyResolverTest.kt | 147 ---- .../apps/tui/input/TambouiKeyMapperTest.kt | 139 ---- .../apps/tui/reducer/ConnectionReducerTest.kt | 51 -- .../tui/reducer/EffectDispatchOrderTest.kt | 50 -- .../apps/tui/reducer/InputReducerTest.kt | 105 --- .../apps/tui/reducer/RootReducerQuitTest.kt | 37 - .../apps/tui/reducer/RootReducerTest.kt | 465 ------------ .../apps/tui/reducer/SessionsReducerTest.kt | 429 ------------ .../tui/reducer/SnapshotPhaseReducerTest.kt | 125 ---- ...provalResponseGlobalSocketRoundTripTest.kt | 59 -- .../apps/tui/ws/TuiWsClientChannelTest.kt | 48 -- .../ws/TuiWsClientConnectionChannelTest.kt | 45 -- settings.gradle | 1 - 60 files changed, 231 insertions(+), 4813 deletions(-) create mode 100644 apps/tui-go/internal/protocol/golden_test.go delete mode 100644 apps/tui/build.gradle delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventStreamPanel.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/FooterBar.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/Theme.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/WelcomePanel.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ConnectionReducer.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ProviderReducer.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ServerMessageReducer.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducer.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/ConnectionState.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/DisplayState.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/ProviderState.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/ws/ConnectionEvent.kt delete mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/SingleConsumerContractTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ConnectionReducerTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/EffectDispatchOrderTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerQuitTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducerTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/ws/ApprovalResponseGlobalSocketRoundTripTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/ws/TuiWsClientChannelTest.kt delete mode 100644 apps/tui/src/test/kotlin/com/correx/apps/tui/ws/TuiWsClientConnectionChannelTest.kt diff --git a/.gitignore b/.gitignore index c36c27bb..6d17b126 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,6 @@ apps/tui-go/bin/ # TUI runtime logs (Kotlin TUI, pre-Go rewrite) apps/tui/logs/ + +# Server runtime logs +apps/server/logs/ diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt index 6e6e6479..21ae5989 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt @@ -10,6 +10,7 @@ import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ChatSessionStartedEvent +import com.correx.core.events.events.ChatTurnEvent import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.InferenceStartedEvent @@ -54,20 +55,29 @@ suspend fun domainEventToServerMessage( ): ServerMessage? { val seq = event.sequence return when (val p = event.payload) { - is ChatSessionStartedEvent -> ServerMessage.SessionStarted( + is ChatSessionStartedEvent -> ServerMessage.SessionAnnounced( sessionId = p.sessionId, workflowId = "chat", sequence = seq, sessionSequence = sessionSequence, ) - is WorkflowStartedEvent -> ServerMessage.SessionStarted( + is WorkflowStartedEvent -> ServerMessage.SessionAnnounced( sessionId = p.sessionId, workflowId = p.workflowId, sequence = seq, sessionSequence = sessionSequence, ) + is ChatTurnEvent -> ServerMessage.ChatTurn( + sessionId = p.sessionId, + turnId = p.turnId, + role = p.role.name, + content = p.content, + sequence = seq, + sessionSequence = sessionSequence, + ) + is WorkflowCompletedEvent -> ServerMessage.SessionCompleted( sessionId = p.sessionId, sequence = seq, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index 37c7f3fc..43fb6c29 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -33,18 +33,34 @@ sealed interface ServerMessage { // -- Session lifecycle -- /** - * @see SessionStartedEvent — emitted when a session begins. - * TODO(cleanup-NN): retire SessionStarted once TUI migrates to event-derived ordering. + * Event-derived session announcement: carries the session's [workflowId] so the client + * can name it. Derived from WorkflowStartedEvent / ChatSessionStartedEvent. The client + * applies it through the same auto-vivify path as any other event (no special-casing). */ @Serializable - @SerialName("session.started") - data class SessionStarted( + @SerialName("session.announced") + data class SessionAnnounced( val sessionId: SessionId, val workflowId: String, override val sequence: Long, override val sessionSequence: Long, ) : ServerMessage, SessionMessage + /** + * Event-derived conversation turn (USER or ROUTER), from ChatTurnEvent. The client + * rebuilds the chat transcript from these, ordered by [sessionSequence]. + */ + @Serializable + @SerialName("chat.turn") + data class ChatTurn( + val sessionId: SessionId, + val turnId: String, + val role: String, + val content: String, + override val sequence: Long, + override val sessionSequence: Long, + ) : ServerMessage, SessionMessage + @Serializable @SerialName("session.paused") data class SessionPaused( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 7a77bbe3..2fad1d0e 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -193,14 +193,11 @@ class GlobalStreamHandler(private val module: ServerModule) { is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame) is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame) is ClientMessage.ChatInput -> { + // The USER and ROUTER turns are emitted as ChatTurnEvents inside onUserInput + // and reach the client as chat.turn frames via streamGlobal — no direct + // RouterResponseMessage (the conversation is event-derived). runCatching { module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode) - }.onSuccess { response -> - sendFrame(ServerMessage.RouterResponseMessage( - sessionId = msg.sessionId, - content = response.content, - steeringEmitted = response.steeringEmitted, - )) }.onFailure { log.error("routerFacade.onUserInput failed: {}", it.message) sendFrame(ServerMessage.ProtocolError("Router error: ${it.message}")) @@ -327,7 +324,8 @@ class GlobalStreamHandler(private val module: ServerModule) { log.info("starting chat session={}", sessionId.value) // Emit ChatSessionStartedEvent for the new session (no graph, no orchestrator). - // streamGlobal picks this up and maps it to SessionStarted with the real sequence. + // streamGlobal maps it to a session.announced frame with the real sequence, and the + // USER/ROUTER turns from onUserInput stream as chat.turn frames — all event-derived. module.eventStore.append(NewEvent( metadata = EventMetadata( eventId = EventId(UUID.randomUUID().toString()), @@ -340,15 +338,8 @@ class GlobalStreamHandler(private val module: ServerModule) { payload = ChatSessionStartedEvent(sessionId = sessionId), )) - // Call router and send response. runCatching { module.routerFacade.onUserInput(sessionId, msg.text) - }.onSuccess { response -> - sendFrame(ServerMessage.RouterResponseMessage( - sessionId = sessionId, - content = response.content, - steeringEmitted = response.steeringEmitted, - )) }.onFailure { log.error("routerFacade.onUserInput failed: {}", it.message) sendFrame(ServerMessage.ProtocolError( @@ -381,9 +372,9 @@ class GlobalStreamHandler(private val module: ServerModule) { // Send StageToolManifest FIRST. If the WS is already closed, this throws and the // orchestrator never launches — no silent data loss. The exception propagates to the // runCatching in handleClientMessage (caller) and is logged there. - // SessionStarted is not sent here — it is derived from WorkflowStartedEvent - // by streamGlobal via DomainEventMapper, ensuring exactly one SessionStarted - // per session with the correct sequence number (P0-5). + // No session announce is sent here — it is derived from WorkflowStartedEvent by + // streamGlobal via DomainEventMapper (one session.announced per session with the + // correct sequence number). val stages = graph.stages.map { (stageId, stageConfig) -> val tools = stageConfig.allowedTools.mapNotNull { toolName -> module.toolRegistry.resolve(toolName)?.let { tool -> diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt index 058f8190..a0a0278e 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt @@ -9,6 +9,8 @@ import com.correx.core.approvals.Tier import com.correx.core.artifactstore.ArtifactStore import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.ChatTurnEvent +import com.correx.core.events.events.ChatTurnRole import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceStartedEvent @@ -88,12 +90,30 @@ class DomainEventMapperTest { ) @Test - fun `WorkflowStartedEvent maps to SessionStarted`(): Unit = runTest { + fun `WorkflowStartedEvent maps to SessionAnnounced`(): Unit = runTest { val event = storedEvent(WorkflowStartedEvent(sessionId = sessionId, startStageId = stageId, workflowId = workflowId)) val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) assertEquals( - ServerMessage.SessionStarted(sessionId, workflowId, event.sequence, 0L), + ServerMessage.SessionAnnounced(sessionId, workflowId, event.sequence, 0L), + result, + ) + } + + @Test + fun `ChatTurnEvent maps to ChatTurn`(): Unit = runTest { + val event = storedEvent( + ChatTurnEvent( + sessionId = sessionId, + turnId = "turn-1", + role = ChatTurnRole.ROUTER, + content = "hello", + timestampMs = 1L, + ), + ) + val result = domainEventToServerMessage(event, noopStore, sessionSequence = 3L) + assertEquals( + ServerMessage.ChatTurn(sessionId, "turn-1", "ROUTER", "hello", event.sequence, 3L), result, ) } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index fbd46d6e..29202b21 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -13,21 +13,38 @@ class ServerMessageSerializationTest { } @Test - fun `SessionStarted encodes sequence and sessionSequence`() { - val msg = ServerMessage.SessionStarted( + fun `SessionAnnounced encodes sequence and sessionSequence`() { + val msg = ServerMessage.SessionAnnounced( sessionId = SessionId("sess-1"), workflowId = "wf-1", sequence = 42, sessionSequence = 7, ) val jsonStr = ProtocolSerializer.encodeServerMessage(msg) - assert(jsonStr.contains("\"type\":\"session.started\"")) { "expected type=session.started" } + assert(jsonStr.contains("\"type\":\"session.announced\"")) { "expected type=session.announced" } assert(jsonStr.contains("\"sessionId\":\"sess-1\"")) { "expected sessionId" } assert(jsonStr.contains("\"workflowId\":\"wf-1\"")) { "expected workflowId" } assert(jsonStr.contains("\"sequence\":42")) { "expected sequence=42" } assert(jsonStr.contains("\"sessionSequence\":7")) { "expected sessionSequence=7" } } + @Test + fun `ChatTurn encodes role content and cursors`() { + val msg = ServerMessage.ChatTurn( + sessionId = SessionId("sess-1"), + turnId = "turn-9", + role = "ROUTER", + content = "hi there", + sequence = 5, + sessionSequence = 2, + ) + val jsonStr = ProtocolSerializer.encodeServerMessage(msg) + assert(jsonStr.contains("\"type\":\"chat.turn\"")) { "expected type=chat.turn" } + assert(jsonStr.contains("\"turnId\":\"turn-9\"")) { "expected turnId" } + assert(jsonStr.contains("\"role\":\"ROUTER\"")) { "expected role" } + assert(jsonStr.contains("\"content\":\"hi there\"")) { "expected content" } + } + @Test fun `SnapshotComplete encodes with only type field`() { val msg = ServerMessage.SnapshotComplete @@ -57,15 +74,15 @@ class ServerMessageSerializationTest { } @Test - fun `SessionStarted round-trips through JSON`() { - val original = ServerMessage.SessionStarted( + fun `SessionAnnounced round-trips through JSON`() { + val original = ServerMessage.SessionAnnounced( sessionId = SessionId("sess-42"), workflowId = "wf-abc", sequence = 200, sessionSequence = 33, ) val jsonStr = ProtocolSerializer.encodeServerMessage(original) - val decoded = json.decodeFromString(jsonStr) + val decoded = json.decodeFromString(jsonStr) assertEquals(original.sessionId, decoded.sessionId) assertEquals(original.workflowId, decoded.workflowId) assertEquals(200L, decoded.sequence) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt index 898b6728..437f6ce8 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/ws/GlobalStreamHandlerTest.kt @@ -158,7 +158,7 @@ class GlobalStreamHandlerTest { // Then 3 WorkflowStarted messages (seq 6, 7, 8) val live = messages.drop(2) assertEquals(3, live.size) - live.forEach { assertInstanceOf(ServerMessage.SessionStarted::class.java, it) } + live.forEach { assertInstanceOf(ServerMessage.SessionAnnounced::class.java, it) } } @Test @@ -198,7 +198,7 @@ class GlobalStreamHandlerTest { } val completeIdx = messages.indexOf(ServerMessage.SnapshotComplete) val afterComplete = messages.drop(completeIdx + 1) - assert(afterComplete.any { msg -> msg is ServerMessage.SessionStarted }) { + assert(afterComplete.any { msg -> msg is ServerMessage.SessionAnnounced }) { "Race event (seq=6) was lost in iteration $it! Messages: $messages" } } @@ -232,7 +232,7 @@ class GlobalStreamHandlerTest { assertEquals(ServerMessage.SnapshotComplete, messages[0]) assertEquals(2, messages.size) - assertInstanceOf(ServerMessage.SessionStarted::class.java, messages[1]) + assertInstanceOf(ServerMessage.SessionAnnounced::class.java, messages[1]) } @Test diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 203d7e39..db177f59 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -237,6 +237,19 @@ func (m Model) session(id string) *Session { return nil } +// ensureSession returns the session with id, creating an ACTIVE entry if absent. +// Session existence is derived from the event stream (any event for an unknown +// session vivifies it) rather than a dedicated control frame. +func (m *Model) ensureSession(id string) *Session { + if s := m.session(id); s != nil { + return s + } + m.sessions = append(m.sessions, Session{ + ID: id, Status: "ACTIVE", LastEventAt: nowMillis(), + }) + return &m.sessions[len(m.sessions)-1] +} + // filteredSessions applies the workflow-id filter. func (m Model) filteredSessions() []Session { if m.filter == "" { diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 163f89a2..31d3790d 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -50,9 +50,24 @@ func inferCategory(t string) string { // applyServer mutates the model for a single (non-buffered) server message. func (m *Model) applyServer(msg protocol.ServerMessage) { debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason) + // Auto-vivify: any event for an unknown session creates it, so session + // existence is derived from the event stream, not a dedicated control frame. + if msg.IsEventBearing() && msg.SessionID != "" { + m.ensureSession(msg.SessionID) + } switch msg.Type { - case protocol.TypeSessionStarted: - m.onSessionStarted(msg) + case protocol.TypeSessionAnnounced: + m.onSessionAnnounced(msg) + case protocol.TypeChatTurn: + m.routerConnected = true + role := "router" + if msg.Role == "USER" { + role = "user" + } + m.appendRouter(msg.SessionID, RouterEntry{role, msg.Content}) + if s := m.session(msg.SessionID); s != nil { + s.LastEventAt = nowMillis() + } case protocol.TypeSessionPaused: label := "PAUSED" if msg.Reason == "APPROVAL_PENDING" { @@ -183,9 +198,6 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } else { m.providerType = "REMOTE" } - case protocol.TypeRouterResponse: - m.routerConnected = true - m.appendRouter(msg.SessionID, RouterEntry{"router", msg.Content}) case protocol.TypeWorkflowList: m.workflows = m.workflows[:0] for _, w := range msg.Workflows { @@ -227,30 +239,23 @@ func sessionIDOf(msg protocol.ServerMessage) string { } } -func (m *Model) onSessionStarted(msg protocol.ServerMessage) { - hadOptimistic := false - cleaned := m.sessions[:0:0] - for _, s := range m.sessions { - if s.Status == "STARTING" { - hadOptimistic = true - continue - } - cleaned = append(cleaned, s) - } - cleaned = append(cleaned, Session{ - ID: msg.SessionID, Status: "ACTIVE", WorkflowID: msg.WorkflowID, - Name: msg.WorkflowID, LastEventAt: nowMillis(), - }) - m.sessions = cleaned - if hadOptimistic || m.selectedID == "" { - m.selectedID = msg.SessionID +// onSessionAnnounced fills in a session's workflow identity (the announce is the +// only event carrying workflowId) and applies auto-focus. The session entry itself +// was already created by the auto-vivify path in applyServer. +func (m *Model) onSessionAnnounced(msg protocol.ServerMessage) { + s := m.ensureSession(msg.SessionID) + s.Status = "ACTIVE" + if msg.WorkflowID != "" { + s.WorkflowID = msg.WorkflowID + s.Name = msg.WorkflowID } + s.LastEventAt = nowMillis() if m.pendingWorkflowFocus { m.selectedID = msg.SessionID m.sessionEntered = true m.pendingWorkflowFocus = false - } else { - m.sessionEntered = true + } else if m.selectedID == "" { + m.selectedID = msg.SessionID } } diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index a3475a26..c6e2e026 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -545,15 +545,14 @@ func (m Model) submit() (tea.Model, tea.Cmd) { if text == "" { return m, nil } - // New optimistic chat session. + // New chat session. The client owns the id, so create the entry locally and + // focus it; the USER/ROUTER turns arrive as chat.turn events (no optimistic echo). id := newSessionID() - m.sessions = append(m.sessions, Session{ - ID: id, Status: "STARTING", WorkflowID: "chat", Name: "chat", - LastEventAt: nowMillis(), - }) + s := m.ensureSession(id) + s.WorkflowID = "chat" + s.Name = "chat" m.selectedID = id m.sessionEntered = true - m.appendRouter(id, RouterEntry{"user", text}) m.client.Send(protocol.StartChatSession(id, text)) m.clearInput() return m, nil @@ -569,7 +568,7 @@ func (m Model) submit() (tea.Model, tea.Cmd) { hist = hist[len(hist)-50:] } m.history[sid] = hist - m.appendRouter(sid, RouterEntry{"user", text}) + // No optimistic echo — the USER turn arrives as a chat.turn event. m.client.Send(protocol.ChatInput(sid, text, m.chatMode)) m.clearInput() return m, nil diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go new file mode 100644 index 00000000..f9d9b198 --- /dev/null +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -0,0 +1,86 @@ +package protocol + +import "testing" + +// Golden wire frames as emitted by the Kotlin server (kotlinx.serialization). These +// strings are the cross-language contract: the Kotlin side pins the encode in +// ServerMessageSerializationTest; this test pins the Go decode of the exact same bytes, +// so a discriminator (@SerialName) or field-name drift fails loudly on one side. +func TestDecodeGoldenServerFrames(t *testing.T) { + cases := []struct { + name string + json string + wantType string + eventBear bool + check func(t *testing.T, m ServerMessage) + }{ + { + name: "session.announced", + json: `{"type":"session.announced","sessionId":"s1","workflowId":"healthcheck","sequence":6,"sessionSequence":1}`, + wantType: TypeSessionAnnounced, + eventBear: true, + check: func(t *testing.T, m ServerMessage) { + if m.SessionID != "s1" || m.WorkflowID != "healthcheck" { + t.Fatalf("session.announced fields: %+v", m) + } + }, + }, + { + name: "chat.turn", + json: `{"type":"chat.turn","sessionId":"s1","turnId":"t9","role":"ROUTER","content":"hi there","sequence":7,"sessionSequence":2}`, + wantType: TypeChatTurn, + eventBear: true, + check: func(t *testing.T, m ServerMessage) { + if m.Role != "ROUTER" || m.Content != "hi there" || m.TurnID != "t9" { + t.Fatalf("chat.turn fields: %+v", m) + } + }, + }, + { + name: "stage.started", + json: `{"type":"stage.started","sessionId":"s1","stageId":"write_script","occurredAt":123,"sequence":8,"sessionSequence":3}`, + wantType: TypeStageStarted, + eventBear: true, + check: func(t *testing.T, m ServerMessage) { + if m.StageID != "write_script" { + t.Fatalf("stage.started fields: %+v", m) + } + }, + }, + { + name: "tool.completed", + json: `{"type":"tool.completed","sessionId":"s1","toolName":"shell","outputSummary":"ok","occurredAt":1,"diff":null,"sequence":9,"sessionSequence":4}`, + wantType: TypeToolCompleted, + eventBear: true, + check: func(t *testing.T, m ServerMessage) { + if m.ToolName != "shell" || m.Summary != "ok" { + t.Fatalf("tool.completed fields: %+v", m) + } + }, + }, + { + name: "snapshot_complete (non-event control frame)", + json: `{"type":"snapshot_complete"}`, + wantType: TypeSnapshotComplete, + eventBear: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m, err := Decode([]byte(c.json)) + if err != nil { + t.Fatalf("decode failed: %v", err) + } + if m.Type != c.wantType { + t.Fatalf("type = %q, want %q", m.Type, c.wantType) + } + if m.IsEventBearing() != c.eventBear { + t.Fatalf("IsEventBearing = %v, want %v", m.IsEventBearing(), c.eventBear) + } + if c.check != nil { + c.check(t, m) + } + }) + } +} diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 19e3be16..2fea4a86 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -1,7 +1,7 @@ // Package protocol implements the correx WebSocket wire contract used to talk // to apps/server over /stream. The server (Kotlin/kotlinx.serialization) uses a // "type" class discriminator. Server->client variants carry @SerialName values -// (e.g. "session.started"); client->server variants have no @SerialName, so the +// (e.g. "session.announced"); client->server variants have no @SerialName, so the // discriminator is the fully-qualified Kotlin class name. package protocol @@ -13,7 +13,8 @@ const clientPrefix = "com.correx.apps.server.protocol.ClientMessage." // Server message discriminator values (@SerialName on ServerMessage variants). const ( - TypeSessionStarted = "session.started" + TypeSessionAnnounced = "session.announced" + TypeChatTurn = "chat.turn" TypeSessionPaused = "session.paused" TypeSessionResumed = "session.resumed" TypeSessionCompleted = "session.completed" @@ -54,6 +55,8 @@ type ServerMessage struct { WorkflowID string `json:"workflowId"` StageID string `json:"stageId"` ToolName string `json:"toolName"` + Role string `json:"role"` + TurnID string `json:"turnId"` Reason string `json:"reason"` Summary string `json:"outputSummary"` Response string `json:"responseText"` @@ -168,7 +171,7 @@ func Decode(raw []byte) (ServerMessage, error) { // and are applied immediately. func (m ServerMessage) IsEventBearing() bool { switch m.Type { - case TypeSessionStarted, TypeSessionPaused, TypeSessionResumed, + case TypeSessionAnnounced, TypeChatTurn, TypeSessionPaused, TypeSessionResumed, TypeSessionCompleted, TypeSessionFailed, TypeStageStarted, TypeStageCompleted, TypeStageFailed, TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout, diff --git a/apps/tui/build.gradle b/apps/tui/build.gradle deleted file mode 100644 index a9485a52..00000000 --- a/apps/tui/build.gradle +++ /dev/null @@ -1,73 +0,0 @@ -plugins { - id 'org.jetbrains.kotlin.jvm' - id 'org.jetbrains.kotlin.plugin.serialization' - id 'application' -} - -application { - mainClass = 'com.correx.apps.tui.TuiAppKt' -} - -repositories { - maven { - url 'https://central.sonatype.com/repository/maven-snapshots/' - mavenContent { snapshotsOnly() } - } -} - -configurations.all { - resolutionStrategy.cacheChangingModulesFor 0, 'seconds' -} - -dependencies { - implementation project(':apps:server') - implementation project(':core:events') - implementation project(':core:approvals') - implementation project(':core:router') - - implementation "io.ktor:ktor-client-core:$ktor_version" - implementation "io.ktor:ktor-client-cio:$ktor_version" - implementation "io.ktor:ktor-client-websockets:$ktor_version" - implementation "io.ktor:ktor-client-logging:$ktor_version" - - implementation platform('dev.tamboui:tamboui-bom:0.3.0') - implementation 'dev.tamboui:tamboui-tui' - implementation 'dev.tamboui:tamboui-widgets' - implementation 'dev.tamboui:tamboui-core' - runtimeOnly 'dev.tamboui:tamboui-jline3-backend' - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version" - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version" - - testImplementation "org.jetbrains.kotlin:kotlin-test" - testImplementation "org.jetbrains.kotlin:kotlin-test-junit5" - testImplementation "org.junit.jupiter:junit-jupiter:5.10.2" - testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_version" - testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.2" -} - -tasks.named('test') { useJUnitPlatform() } - -tasks.register('tuiStartScripts', CreateStartScripts) { - mainClass = 'com.correx.apps.tui.TuiAppKt' - applicationName = 'tui' - outputDir = file("$buildDir/scripts-tui") - classpath = tasks.named('startScripts').get().classpath -} - -distributions.named('main').configure { - contents { - from(tasks.named('tuiStartScripts')) { into 'bin' } - } -} - -tasks.withType(Tar).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } -tasks.withType(Zip).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } -tasks.named("installDist") { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } - -configurations.configureEach { - resolutionStrategy.force( - "com.github.ajalt.mordant:mordant:2.6.0", - "com.github.ajalt.mordant:mordant-jvm:2.6.0" - ) -} -tasks.named("koverVerify").configure { enabled = false } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt deleted file mode 100644 index 8e9fc9d2..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.correx.apps.tui - -sealed class KeyEvent { - object Quit : KeyEvent() - object NewSession : KeyEvent() - object Cancel : KeyEvent() - object Approve : KeyEvent() - object Reject : KeyEvent() - object NavUp : KeyEvent() - object NavDown : KeyEvent() - object Filter : KeyEvent() - object Tab : KeyEvent() - object Enter : KeyEvent() - object Backspace : KeyEvent() - object Escape : KeyEvent() - object ToggleEventStrip : KeyEvent() - object ReturnToSessionList : KeyEvent() - object ShowPendingApproval : KeyEvent() - data class CharInput(val ch: Char) : KeyEvent() - object CursorLeft : KeyEvent() - object CursorRight : KeyEvent() - object ToggleWorkflows : KeyEvent() - object ToggleSteeringMode : KeyEvent() - object ToggleDiffExpand : KeyEvent() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt deleted file mode 100644 index a89fff11..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt +++ /dev/null @@ -1,248 +0,0 @@ -package com.correx.apps.tui - -import com.correx.apps.tui.components.approvalSurfaceWidget -import com.correx.apps.tui.components.eventStreamPanelWidget -import com.correx.apps.tui.components.filteredSessions -import com.correx.apps.tui.components.footerBarWidget -import com.correx.apps.tui.components.inputBarWidget -import com.correx.apps.tui.components.routerPanelWidget -import com.correx.apps.tui.components.sessionListWidget -import com.correx.apps.tui.components.statusBarWidget -import com.correx.apps.tui.components.welcomePanelWidget -import com.correx.apps.tui.components.workflowListWidget -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.input.KeyResolver -import com.correx.apps.tui.input.mapKey -import com.correx.apps.tui.reducer.Effect -import com.correx.apps.tui.reducer.EffectDispatcher -import com.correx.apps.tui.reducer.RootReducer -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.TuiState -import com.correx.apps.tui.state.displayState -import com.correx.apps.tui.state.selectedPendingApproval -import com.correx.apps.tui.ws.ConnectionEvent -import com.correx.apps.tui.ws.TuiWsClient -import dev.tamboui.layout.Constraint -import dev.tamboui.layout.Layout -import dev.tamboui.terminal.Frame -import dev.tamboui.tui.EventHandler -import dev.tamboui.tui.Renderer -import dev.tamboui.tui.TuiRunner -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.slf4j.LoggerFactory -import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent - -private const val DEFAULT_PORT = 8080 -private const val STATUS_HEIGHT = 1 -private const val INPUT_HEIGHT = 4 -private const val FOOTER_HEIGHT = 1 -private const val MAX_VISIBLE_SESSIONS = 7 -private const val SESSION_BORDER_PADDING = 2 -private const val WORKFLOW_HEIGHT = 6 - -private val log = LoggerFactory.getLogger("com.correx.apps.tui.main") - -fun main(args: Array) { - val host = args.getOrElse(0) { "localhost" } - val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT - runBlocking { - var state = TuiState() - val ws = TuiWsClient(host = host, port = port) - val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - TuiRunner.create().use { runner -> - val dispatcher = EffectDispatcher(ws) { runner.quit() } - - fun dispatch(action: Action) { - val (next, effects) = RootReducer.reduce(state, action) - log.debug("got connection state after reducers: {}", next.connection) - log.debug("got input state after reducers: {}", next.input) - log.debug( - "got session state after reducers: {}", - next.sessions.sessions.firstOrNull { it.status != "COMPLETED" }, - ) - state = next - if (effects.isNotEmpty()) { - effectScope.launch { - dispatchAll(dispatcher, effects) - } - } - } - - effectScope.launch { - ws.messages.collect { msg -> - runner.runOnRenderThread { dispatch(Action.ServerEventReceived(msg)) } - } - } - effectScope.launch { - ws.connection.collect { ev -> - val action = when (ev) { - is ConnectionEvent.Connected -> Action.Connected - is ConnectionEvent.Disconnected -> Action.Disconnected - is ConnectionEvent.RetryScheduled -> Action.RetryScheduled(ev.attempt, ev.nextRetryAtMs) - } - runner.runOnRenderThread { dispatch(action) } - } - } - effectScope.launch { ws.connect() } - - val handler = EventHandler { event, _ -> - if (event is TambouiKeyEvent) { - mapKey(event) - ?.let { key -> - val hasPendingApproval = state.selectedPendingApproval() != null - KeyResolver.resolve(key, state.displayState, state.inputMode, hasPendingApproval, state.inputBuffer) - } - ?.let(::dispatch) - } - true - } - - val renderer = Renderer { frame -> render(frame, state) } - - runner.run(handler, renderer) - } - } -} - -internal suspend fun dispatchAll(dispatcher: EffectDispatcher, effects: List) { - for (effect in effects) { - dispatcher.dispatch(effect) - } -} - -private fun render(frame: Frame, state: TuiState) { - when (state.displayState) { - DisplayState.IDLE -> renderIdleLayout(frame, state) - DisplayState.IN_SESSION -> renderInSessionLayout(frame, state) - DisplayState.APPROVAL -> renderApprovalLayout(frame, state) - } -} - -/** - * 3-row grid: status bar | main area (horizontal split 1.7:1) | footer bar - * Left panel: session list [+ workflow list] + input bar footer - * Right panel: welcome panel - */ -private fun renderIdleLayout(frame: Frame, state: TuiState) { - val area = frame.area() - val vertRects = Layout.vertical() - .constraints( - Constraint.length(STATUS_HEIGHT), - Constraint.fill(), // main area - Constraint.length(FOOTER_HEIGHT), - ) - .split(area) - - val mainRects = Layout.horizontal() - .constraints(Constraint.fill(17), Constraint.fill(10)) // 1.7:1 - .split(vertRects[1]) - - frame.renderWidget(statusBarWidget(state), vertRects[0]) - renderLeftPanelWithInputBar(frame, state, mainRects[0]) { leftRect -> - renderIdleLeftContent(frame, state, leftRect) - } - frame.renderWidget(welcomePanelWidget(state), mainRects[1]) - frame.renderWidget(footerBarWidget(state), vertRects[2]) -} - -/** - * 3-row grid: status bar | main area (horizontal split 1.7:1) | footer bar - * Left panel: router conversation + input bar footer - * Right panel: event stream - */ -private fun renderInSessionLayout(frame: Frame, state: TuiState) { - val area = frame.area() - val vertRects = Layout.vertical() - .constraints( - Constraint.length(STATUS_HEIGHT), - Constraint.fill(), // main area - Constraint.length(FOOTER_HEIGHT), - ) - .split(area) - - val mainRects = Layout.horizontal() - .constraints(Constraint.fill(17), Constraint.fill(10)) // 1.7:1 - .split(vertRects[1]) - - frame.renderWidget(statusBarWidget(state), vertRects[0]) - renderLeftPanelWithInputBar(frame, state, mainRects[0]) { leftRect -> - frame.renderWidget(routerPanelWidget(state), leftRect) - } - frame.renderWidget(eventStreamPanelWidget(state), mainRects[1]) - frame.renderWidget(footerBarWidget(state), vertRects[2]) -} - -/** - * 3-row grid: status bar | main area (full width, no split) | footer bar - * Main area shows the approval surface. - */ -private fun renderApprovalLayout(frame: Frame, state: TuiState) { - val area = frame.area() - val vertRects = Layout.vertical() - .constraints( - Constraint.length(STATUS_HEIGHT), - Constraint.fill(), // main area - Constraint.length(FOOTER_HEIGHT), - ) - .split(area) - - frame.renderWidget(statusBarWidget(state), vertRects[0]) - frame.renderWidget(approvalSurfaceWidget(state), vertRects[1]) - frame.renderWidget(footerBarWidget(state), vertRects[2]) -} - -/** - * Renders the left panel content in the upper portion of [leftRect], with the - * InputBar pinned to the bottom. The [contentRenderer] draws the primary content - * (session list for IDLE, router panel for IN_SESSION) into the top sub-rect. - */ -private fun renderLeftPanelWithInputBar( - frame: Frame, - state: TuiState, - leftRect: dev.tamboui.layout.Rect, - contentRenderer: (dev.tamboui.layout.Rect) -> Unit, -) { - val panelRects = Layout.vertical() - .constraints( - Constraint.fill(), // content area - Constraint.length(INPUT_HEIGHT), // input bar footer - ) - .split(leftRect) - - contentRenderer(panelRects[0]) - frame.renderWidget(inputBarWidget(state, state.displayState), panelRects[1]) -} - -/** - * Renders the session list and (optionally) the workflow picker in the IDLE left panel. - */ -private fun renderIdleLeftContent(frame: Frame, state: TuiState, rect: dev.tamboui.layout.Rect) { - val sessionCount = filteredSessions(state.sessions).size - val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING - val hasWorkflows = state.sessions.workflows.isNotEmpty() - val showWorkflows = hasWorkflows && (state.sessions.workflowsVisible || state.sessions.sessions.isEmpty()) - - val constraints = mutableListOf( - Constraint.length(sessionListHeight), - ) - if (showWorkflows) { - constraints.add(Constraint.length(WORKFLOW_HEIGHT)) - } - constraints.add(Constraint.fill()) - - val contentRects = Layout.vertical() - .constraints(constraints) - .split(rect) - - var rectIdx = 0 - frame.renderWidget(sessionListWidget(state), contentRects[rectIdx++]) - if (showWorkflows) { - frame.renderWidget(workflowListWidget(state), contentRects[rectIdx++]) - } - // Remaining space is unused (padding at the bottom of the left panel) -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt deleted file mode 100644 index a73e86c6..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt +++ /dev/null @@ -1,115 +0,0 @@ -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.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 -private const val DIFF_LINE_MAX_LENGTH = 100 - -fun approvalSurfaceWidget(state: TuiState): Paragraph { - val approval = state.selectedPendingApproval() - val pendingDecision = state.pendingDecision - - val diffExpanded = state.diffExpanded - val scrollOffset = state.diffScrollOffset - - val lines = buildList { - if (approval == null) { - add(Line.from(Span.styled("no pending approval", Theme.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}", Theme.warnStyle) - val riskSpan = Span.styled(approval.riskSummary, Theme.dimStyle) - add(Line.from(toolSpan, Span.raw(" "), tierBadge, Span.raw(" "), riskSpan)) - - // Preview: unified diff for file_write/file_edit, raw text otherwise - val preview = approval.preview - val isDiff = preview?.startsWith("--- a/") == true - if (preview != null) { - val previewLines = preview.lines() - if (previewLines.isNotEmpty()) { - val totalLines = previewLines.size - val visibleLines = if (diffExpanded) { - val from = scrollOffset.coerceIn(0, maxOf(0, totalLines - 1)) - val to = minOf(from + PREVIEW_MAX_LINES, totalLines) - previewLines.subList(from, to) - } else { - previewLines.take(PREVIEW_MAX_LINES) - } - for (line in visibleLines) { - if (isDiff) { - val truncated = if (line.length > DIFF_LINE_MAX_LENGTH && !diffExpanded) { - line.take(DIFF_LINE_MAX_LENGTH - 3) + "..." - } else { - line - } - val span = when { - truncated.startsWith("@@") -> Span.styled(" $truncated", Theme.warnStyle) - truncated.startsWith("+++") || truncated.startsWith("---") -> Span.styled(" $truncated", Theme.dimStyle) - truncated.startsWith("+") -> Span.styled(" $truncated", Theme.okStyle) - truncated.startsWith("-") -> Span.styled(" $truncated", Theme.badStyle) - else -> Span.raw(" $truncated") - } - add(Line.from(span)) - } else { - add(Line.from(Span.raw(" $line"))) - } - } - if (!diffExpanded && totalLines > PREVIEW_MAX_LINES) { - val remaining = totalLines - PREVIEW_MAX_LINES - add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand, \u2191\u2193 to scroll)", Theme.dimStyle))) - } - if (diffExpanded) { - val showingTo = minOf(scrollOffset + PREVIEW_MAX_LINES, totalLines) - add(Line.from(Span.styled(" lines ${scrollOffset + 1}\u2013$showingTo of $totalLines — \u2191\u2193 scroll, ctrl+x collapse", Theme.dimStyle))) - } - } else { - add(Line.from(Span.styled(" no preview available", Theme.dimStyle))) - } - } else { - add(Line.from(Span.styled(" no preview available", Theme.dimStyle))) - } - - // Steering note input indicator - add(Line.from(Span.styled(" steering note: ", Theme.dimStyle))) - - // Pending decision indicator (color coded) - val decisionSpan = when (pendingDecision) { - null -> Span.styled(" decision: \u2014", Theme.dimStyle) - ApprovalDecision.APPROVE -> Span.styled(" decision: APPROVE", Theme.okStyle) - ApprovalDecision.REJECT -> Span.styled(" decision: REJECT", Theme.badStyle) - } - add(Line.from(decisionSpan)) - - // Approve/reject key hints - add(Line.from(Span.styled( - " ctrl+a approve \u00B7 ctrl+r reject \u00B7 ctrl+x toggle diff \u00B7 enter submit \u00B7 esc dismiss", - Theme.dimStyle - ))) - } - } - - val block = Block.builder() - .title(Title.from(Span.styled("approval", Theme.warnStyle)).centered()) - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .borderColor(Theme.warn) - .build() - - return Paragraph.builder() - .text(Text.from(lines)) - .block(block) - .build() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt deleted file mode 100644 index 6fd102aa..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.correx.apps.tui.components - -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 selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } - - val lines = buildList { - if (selectedSession == null) { - add(Line.from(Span.styled("no session selected", Theme.dimStyle))) - } else { - // Stage ID and status line (compact, dim metadata) - val stageInfo = buildString { - val stageId = selectedSession.currentStageId - val stage = selectedSession.currentStage - when { - stageId != null -> append(stageId) - stage != null -> append(stage) - else -> append("(no stage)") - } - append(" ") - append(selectedSession.status) - } - add(Line.from(Span.styled(stageInfo, Theme.dimStyle))) - - // Recent events (plain text, compact format) — capped to fit allocated height. - // Line 0 = stage info, lines 1-5 = events (max 5). - val events = selectedSession.recentEvents.takeLast(5) - if (events.isEmpty()) { - add(Line.from(Span.styled("no events", Theme.dimStyle))) - } else { - for (ev in events) { - val typePadded = ev.type.padEnd(TYPE_WIDTH, ' ') - val typeStyle = when (ev.type) { - "StageCompleted", "InferenceCompleted", "ToolCompleted", "SessionCompleted" -> Theme.okStyle - "StageFailed", "SessionFailed", "ToolFailed", "ToolRejected" -> Theme.badStyle - "SessionPaused", "InferenceTimeout" -> Theme.warnStyle - "StageStarted", "InferenceStarted", "ToolStarted" -> Theme.accentStyle - "ArtifactCreated" -> Style.create().fg(Theme.categoryColor("context")) - else -> Style.create() - } - val detail = if (ev.detail.length > DETAIL_MAX) { - ev.detail.take(DETAIL_MAX - 3) + "..." - } else { - ev.detail - } - add(Line.from( - Span.styled("[${ev.timestamp}] ", Theme.dimStyle), - Span.styled("[$typePadded]", typeStyle), - Span.raw(" $detail"), - )) - } - } - } - } - - return Paragraph.builder() - .text(Text.from(lines)) - .build() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventStreamPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventStreamPanel.kt deleted file mode 100644 index 392fb6f9..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventStreamPanel.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.correx.apps.tui.components - -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 TIMESTAMP_WIDTH = 8 -private const val CATEGORY_WIDTH = 12 -private const val TYPE_WIDTH = 18 -private const val MAX_VISIBLE_EVENTS = 20 -private const val DETAIL_MAX = 40 - -/** - * Structured event stream panel — right column in IN_SESSION state. - * - * Event row format: - * ``` - * HH:MM:SS [Category] Type Detail - * 14:11:02 [Lifecycle] stage.enter implementation (role=coder) - * 14:11:19 [Tool] fs.write SegmentStore.kt (+212) - * ``` - */ -fun eventStreamPanelWidget(state: TuiState): Paragraph { - val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } - - val lines = buildList { - // Header: "event stream" + live/paused indicator - val hasPendingApproval = selectedSession?.pendingApproval != null - val liveIndicator = if (hasPendingApproval) { - Span.styled("\u280B paused", Theme.warnStyle) // ⠋ paused - } else { - Span.styled("\u25CC live", Theme.okStyle) // ◌ live - } - add(Line.from( - Span.styled("event stream", Theme.accentStyle), - Span.raw(" "), - Span.styled("\u2502", Theme.dimStyle), - Span.raw(" "), - liveIndicator, - )) - - // Separator - add(Line.from(Span.styled("\u2500".repeat(40), Theme.dimStyle))) - - if (selectedSession == null) { - add(Line.from(Span.styled(" no session selected", Theme.dimStyle))) - } else { - val events = selectedSession.recentEvents.takeLast(MAX_VISIBLE_EVENTS) - if (events.isEmpty()) { - add(Line.from(Span.styled(" no events", Theme.dimStyle))) - } else { - for (ev in events) { - val category = inferCategory(ev.type) - val catColor = Theme.categoryColor(category) - val catStyle = Style.create().fg(catColor) - - val timestamp = ev.timestamp.take(TIMESTAMP_WIDTH).padStart(TIMESTAMP_WIDTH) - val categoryPadded = "[$category]".padEnd(CATEGORY_WIDTH) - val typePadded = ev.type.padEnd(TYPE_WIDTH).take(TYPE_WIDTH) - val detail = if (ev.detail.length > DETAIL_MAX) { - ev.detail.take(DETAIL_MAX - 3) + "..." - } else { - ev.detail - } - - add(Line.from( - Span.styled(timestamp, Theme.dimStyle), - Span.raw(" "), - Span.styled(categoryPadded, catStyle), - Span.styled(typePadded, Theme.fgStrongStyle), - Span.raw(" "), - Span.styled(detail, Style.create().fg(Theme.fg)), - )) - } - } - } - } - - val block = Block.builder() - .title("events") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - - return Paragraph.builder() - .text(Text.from(lines)) - .block(block) - .build() -} - -/** - * Infers the event category from the event type name. - * - * | Type contains | Category | - * |------------------|------------| - * | Stage, Lifecycle | Lifecycle | - * | Infer | Inference | - * | Tool | Tool | - * | Session, Artifact| Domain | - * | Approval | Approval | - * | Context | Context | - */ -private fun inferCategory(type: String): String { - val t = type.lowercase() - return when { - t.contains("stage") || t.contains("lifecycle") -> "Lifecycle" - t.contains("infer") -> "Inference" - t.contains("tool") -> "Tool" - t.contains("session") || t.contains("artifact") -> "Domain" - t.contains("approval") -> "Approval" - t.contains("context") -> "Context" - else -> "Lifecycle" - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/FooterBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/FooterBar.kt deleted file mode 100644 index 3c8ef146..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/FooterBar.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.TuiState -import com.correx.apps.tui.state.displayState -import dev.tamboui.text.Line -import dev.tamboui.text.Span -import dev.tamboui.text.Text -import dev.tamboui.widgets.paragraph.Paragraph - -/** - * Bottom bar showing contextual keybind hints and the current mode indicator. - * - * Keybind chip format: `key label` with key in accent and label in dim. - * Mode indicator right-aligned: `Soft · blue`. - */ -fun footerBarWidget(state: TuiState): Paragraph { - val hints = buildList { - when (state.displayState) { - DisplayState.IDLE -> { - chip("i", "message") - chip("p", "commands") - chip("a", "approval") - chip("e", "events") - chip("F2", "skin") - } - DisplayState.IN_SESSION -> { - chip("i", "message") - chip("p", "commands") - chip("a", "approval") - chip("e", "events") - chip("Tab", "panels") - chip("\u2191\u2193", "history") - chip("l", "back") - chip("F2", "skin") - } - DisplayState.APPROVAL -> { - chip("a", "approve") - chip("A", "auto") - chip("s", "steer") - chip("r", "reject") - chip("esc", "later") - } - } - } - - val modeIndicator = Span.styled("Soft \u00B7 blue", Theme.dimStyle) - - return Paragraph.builder() - .text(Text.from(Line.from(hints + Span.raw(" ") + modeIndicator))) - .build() -} - -/** - * Appends a keybind chip to the builder: ` key label `. - * Key in accent, label in dim, single-space separators. - */ -private fun MutableList.chip(key: String, label: String) { - add(Span.raw(" ")) - add(Span.styled(key, Theme.accentStyle)) - add(Span.styled(" $label", Theme.dimStyle)) - add(Span.raw(" ")) -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt deleted file mode 100644 index 4412ebac..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.server.protocol.ApprovalDecision -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.InputMode -import com.correx.apps.tui.state.TuiState -import dev.tamboui.style.Style -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 fun promptStyle(inputMode: InputMode): Style = when (inputMode) { - InputMode.ROUTER -> Theme.accentStyle - InputMode.FILTER -> Theme.warnStyle -} - -private fun chatModeLabel(state: TuiState): Span = when (state.chatMode) { - com.correx.core.router.ChatMode.CHAT -> Span.styled("chat", Theme.accentStyle) - com.correx.core.router.ChatMode.STEERING -> Span.styled("steer", Theme.accent2Style) -} - -private fun cursorLine(buffer: String, cursor: Int, inputMode: InputMode): Line { - val prompt = Span.styled("▌", promptStyle(inputMode)) - if (buffer.isEmpty()) return Line.from(prompt, Span.raw("")) - val before = buffer.take(cursor) - val after = buffer.drop(cursor) - return if (cursor >= buffer.length) { - Line.from(prompt, Span.raw(" $before"), Span.styled(" ", Style.create().reversed())) - } else { - Line.from(prompt, Span.raw(" $before"), Span.styled(buffer[cursor].toString(), Style.create().reversed()), Span.raw(after.drop(1))) - } -} - -fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph { - val sessionName = state.sessions.sessions - .firstOrNull { it.id == state.sessions.selectedId } - ?.name?.ifEmpty { null } - ?: state.sessions.selectedId?.take(6) - ?: "(no session)" - - val (row1, row2) = when (displayState) { - DisplayState.IDLE -> when (state.inputMode) { - InputMode.ROUTER -> createRowsForIdleRouter(state, sessionName) - InputMode.FILTER -> createRowsForFilter(state) - } - - DisplayState.IN_SESSION -> when (state.inputMode) { - InputMode.ROUTER -> createRowsForInSessionRouter(state, sessionName) - InputMode.FILTER -> createRowsForFilter(state) - } - - DisplayState.APPROVAL -> when (state.inputMode) { - InputMode.ROUTER -> createRowsForApprovalRouter(state) - InputMode.FILTER -> createRowsForFilter(state) - } - } - - val block = Block.builder() - .title("") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - - return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build() -} - -private fun createRowsForIdleRouter( - state: TuiState, - sessionName: String, -): Pair { - val row1 = if (state.inputBuffer.isNotEmpty()) { - cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER) - } else { - Line.from(Span.styled("▌", promptStyle(InputMode.ROUTER)), Span.styled(" Session name…", Theme.dimStyle)) - } - return row1 to Line.from( - Span.styled(sessionName, Theme.accentStyle), - Span.styled(" · ", Theme.dimStyle), - chatModeLabel(state), - ) -} - -private fun createRowsForInSessionRouter( - state: TuiState, - sessionName: String, -): Pair { - val row1 = if (state.inputBuffer.isNotEmpty()) { - cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER) - } else { - Line.from(Span.styled("▌", promptStyle(InputMode.ROUTER)), Span.styled(" Ask anything…", Theme.dimStyle)) - } - return row1 to Line.from( - Span.styled(sessionName, Theme.accentStyle), - Span.styled(" · ", Theme.dimStyle), - chatModeLabel(state), - ) -} - -private fun createRowsForApprovalRouter( - state: TuiState, -): Pair { - val row1 = if (state.inputBuffer.isNotEmpty()) { - cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER) - } else { - Line.from(Span.styled("▌", promptStyle(InputMode.ROUTER)), Span.styled(" Steering note (optional)…", Theme.dimStyle)) - } - - val decisionSpans = when (state.pendingDecision) { - null -> listOf() - ApprovalDecision.APPROVE -> listOf(Span.styled(" [decision: ", Theme.dimStyle), Span.styled("APPROVE", Theme.okStyle), Span.styled("]", Theme.dimStyle)) - ApprovalDecision.REJECT -> listOf(Span.styled(" [decision: ", Theme.dimStyle), Span.styled("REJECT", Theme.badStyle), Span.styled("]", Theme.dimStyle)) - } - - return row1 to Line.from( - Span.styled("approval", Theme.warnStyle), - *decisionSpans.toTypedArray(), - ) -} - -private fun createRowsForFilter( - state: TuiState, -): Pair { - val row1 = if (state.inputBuffer.isNotEmpty()) { - cursorLine(state.inputBuffer, state.inputCursor, InputMode.FILTER) - } else { - Line.from(Span.styled("▌", promptStyle(InputMode.FILTER)), Span.styled(" /filter…", Theme.dimStyle)) - } - return row1 to Line.from( - Span.styled("filtering", Theme.warnStyle), - ) -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt deleted file mode 100644 index 133217be..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.tui.state.RouterEntry -import com.correx.apps.tui.state.TuiState -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 routerPanelWidget(state: TuiState): Paragraph { - val lines = buildList { - if (!state.routerConnected) { - add(Line.from(Span.styled("awaiting router response...", Theme.dimStyle))) - } else { - val msgs = state.routerMessages[state.sessions.selectedId].orEmpty() - for (entry in msgs) { - val style = when (entry.role) { - "user" -> Theme.accentStyle - "router" -> Theme.fgStrongStyle - "tool" -> Theme.dimStyle - else -> Theme.fgStrongStyle - } - val prefix = when (entry.role) { - "user" -> "\u25b8 " - "router" -> " " - "tool" -> " " - else -> "" - } - val contentLines = entry.content.split("\n") - contentLines.forEachIndexed { i, lineContent -> - val text = if (i == 0) "$prefix$lineContent" else " $lineContent" - add(Line.from(Span.styled(text, style))) - } - } - } - } - - val block = Block.builder() - .title("output") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - - return Paragraph.builder().text(Text.from(lines)).block(block).build() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt deleted file mode 100644 index 325c8509..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.tui.state.SessionSummary -import com.correx.apps.tui.state.SessionsState -import com.correx.apps.tui.state.TuiState -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 MAX_VISIBLE = 7 -private const val NAME_MAX = 15 -private const val ID_LEN = 6 -private const val STATUS_WIDTH = 8 - -fun sessionListWidget(state: TuiState): Paragraph { - val sessions = filteredSessions(state.sessions) - val visible = sessions.take(MAX_VISIBLE) - val overflow = sessions.size - MAX_VISIBLE - - val lines = buildList { - for (s in visible) { - val isSelected = s.id == state.sessions.selectedId - val prefix = if (isSelected) Span.styled("▶ ", Theme.accentStyle) else Span.raw(" ") - val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", Theme.dimStyle) - val statusSpan = statusSpan(s) - val rawName = s.name.ifEmpty { s.workflowId } - val truncated = if (rawName.length > NAME_MAX) rawName.take(NAME_MAX - 1) + "…" else rawName - val nameAndStage = buildString { - append(truncated) - if (s.currentStageId != null) append(" [${s.currentStageId}]") - } - add(Line.from(prefix, idSpan, Span.raw(" "), statusSpan, Span.raw(" $nameAndStage"))) - } - if (overflow > 0) { - add(Line.from(Span.styled("— $overflow more —", Theme.dimStyle))) - } - } - - val block = Block.builder() - .title("sessions") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - - return Paragraph.builder().text(Text.from(lines)).block(block).build() -} - -private fun statusSpan(s: SessionSummary): Span { - val label = s.status.uppercase().padEnd(STATUS_WIDTH) - return when (s.status.lowercase()) { - "active", "running" -> Span.styled(label, Theme.okStyle) - "paused" -> Span.styled(label, Theme.warnStyle) - "failed", "error" -> Span.styled(label, Theme.badStyle) - "completed", "done" -> Span.styled(label, Theme.dimStyle) - else -> Span.raw(label) - } -} - -fun filteredSessions(sessions: SessionsState): List { - val f = sessions.filter - return if (f.isEmpty()) sessions.sessions - else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt deleted file mode 100644 index c1eee449..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.ProviderType -import com.correx.apps.tui.state.TuiState -import com.correx.apps.tui.state.displayState -import dev.tamboui.text.Line -import dev.tamboui.text.Span -import dev.tamboui.text.Text -import dev.tamboui.widgets.paragraph.Paragraph - -private const val METER_BARS = 10 - -fun statusBarWidget(state: TuiState): Paragraph { - val sep = Span.styled(" \u00B7 ", Theme.dimStyle) - - // 1. Brand mark: "corre" + accented "x" - val brand = listOf( - Span.styled("corre", Theme.dimStyle), - Span.styled("x", Theme.accentStyle), - ) - - // 2. Connection indicator - val connIndicator = if (state.connection.connected) { - Span.styled("\u25CF connected", Theme.okStyle) - } else { - Span.styled("\u25CB disconnected", Theme.dimBadStyle) - } - - // 3. Session name + branch (only when IN_SESSION or APPROVAL) - val sessionInfo = run { - if (state.displayState == DisplayState.IDLE) return@run emptyList() - val selected = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } - ?: return@run emptyList() - val name = selected.name.ifEmpty { selected.workflowId } - val stage = selected.currentStageId ?: selected.currentStage - buildList { - add(Span.styled(name, Theme.fgStrongStyle)) - if (stage != null) { - add(Span.styled(" \u00B7 $stage", Theme.dimStyle)) - } - } - } - - // 4. Model + provider - val modelLabel = (state.currentModel ?: "(no model)") + - if (state.providerType == ProviderType.LOCAL) " (local)" else " (remote)" - val modelSpan = Span.styled(modelLabel, Theme.accentStyle) - - // 5. Context meter - val contextMeter = run { - val used = state.contextUsed - val budget = state.contextBudget - if (used == null || budget == null || budget <= 0) return@run null - val ratio = (used.toFloat() / budget).coerceIn(0f, 1f) - val filled = (ratio * METER_BARS).toInt().coerceIn(0, METER_BARS) - val empty = METER_BARS - filled - val bar = buildString { - repeat(filled) { append('\u2588') } // █ full block - repeat(empty) { append('\u2591') } // ░ light shade - } - val pct = (ratio * 100).toInt() - val barSpan = Span.styled(bar, Theme.accentStyle) - val pctSpan = Span.styled(" $pct%", Theme.dimStyle) - Span.styled("ctx", Theme.dimStyle) to listOf(barSpan, pctSpan) - } - - // 6. State badge - val stateBadge = run { - when (state.displayState) { - DisplayState.APPROVAL -> Span.styled("\u23F8 awaiting", Theme.warnStyle) - DisplayState.IN_SESSION -> Span.styled("\u25B8 active", Theme.okStyle) - DisplayState.IDLE -> null - } - } - - // 7. Background update badge - val backgroundBadge = if (state.displayState != DisplayState.IDLE && state.sessions.backgroundUpdateCount > 0) { - Span.styled("+${state.sessions.backgroundUpdateCount} updated", Theme.dimWarnStyle) - } else { - null - } - - // 8. Pending approval indicator - val approvalBadge = run { - val has = state.sessions.sessions.any { it.pendingApproval != null } - if (has) Span.styled("[\u26A0 approval]", Theme.warnStyle) else null - } - - val spans = buildList { - addAll(brand) - add(sep) - add(connIndicator) - if (sessionInfo.isNotEmpty()) { - add(sep) - addAll(sessionInfo) - } - add(sep) - add(modelSpan) - if (contextMeter != null) { - add(sep) - add(contextMeter.first) - add(Span.raw(" ")) - addAll(contextMeter.second) - } - if (stateBadge != null) { - add(Span.raw(" ")) - add(stateBadge) - } - if (backgroundBadge != null) { - add(sep) - add(backgroundBadge) - } - if (approvalBadge != null) { - add(sep) - add(approvalBadge) - } - } - - return Paragraph.builder() - .text(Text.from(Line.from(spans))) - .build() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Theme.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Theme.kt deleted file mode 100644 index c7214fd9..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Theme.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.correx.apps.tui.components - -import dev.tamboui.style.Color -import dev.tamboui.style.Style - -/** - * Central theme for the Correx TUI — soft rounded dark palette with blue accent (#4a9eff). - * - * Every widget references Theme.* constants instead of hardcoded `.blue()` / `.cyan()` / `.gray()`. - * - * ## Palette - * - * | Token | Hex | ANSI | Use | - * |-------------|-----------|---------|-------------------------------| - * | `bg` | `#14161a` | idx 233 | Screen background | - * | `bgDeep` | `#0f1115` | idx 232 | Top / footer bar | - * | `panel` | `#181b21` | idx 234 | Panel surface | - * | `panel2` | `#1f232b` | idx 235 | Secondary surface | - * | `fg` | `#ced3da` | idx 252 | Body text | - * | `fgStrong` | `#eef1f5` | idx 255 | Headings, emphasis | - * | `dim` | `#838b96` | idx 243 | Muted text | - * | `faint` | `#434a55` | idx 238 | Borders, placeholders | - * | `border` | `#262b33` | idx 236 | Panel borders | - * | `sel` | `#21262e` | idx 235 | Selection highlight | - * | `accent` | `#4a9eff` | rgb | Blue accent | - * | `accent2` | `#7aa2f7` | rgb | Purple-blue secondary | - * | `ok` | `#7fd88f` | rgb | Success | - * | `warn` | `#e8c06a` | rgb | Warning | - * | `bad` | `#e87f7f` | rgb | Error | - */ -object Theme { - // ── Surfaces ────────────────────────────────────────────── - val bg: Color = Color.indexed(233) - val bgDeep: Color = Color.indexed(232) - val panel: Color = Color.indexed(234) - val panel2: Color = Color.indexed(235) - - // ── Text ────────────────────────────────────────────────── - val fg: Color = Color.indexed(252) - val fgStrong: Color = Color.indexed(255) - val dim: Color = Color.indexed(243) - val faint: Color = Color.indexed(238) - - // ── Accent ──────────────────────────────────────────────── - val accent: Color = Color.rgb(74, 158, 255) - val accent2: Color = Color.rgb(122, 162, 247) - - // ── Semantic ────────────────────────────────────────────── - val ok: Color = Color.rgb(127, 216, 143) - val warn: Color = Color.rgb(232, 192, 106) - val bad: Color = Color.rgb(232, 127, 127) - - // ── Border / Selection ──────────────────────────────────── - val border: Color = Color.indexed(236) - val sel: Color = Color.indexed(235) - - // ── Pre-built Style objects ─────────────────────────────── - val dimStyle: Style = Style.create().fg(dim) - val accentStyle: Style = Style.create().fg(accent) - val okStyle: Style = Style.create().fg(ok) - val warnStyle: Style = Style.create().fg(warn) - val badStyle: Style = Style.create().fg(bad) - val fgStrongStyle: Style = Style.create().fg(fgStrong) - val accent2Style: Style = Style.create().fg(accent2) - - // Convenience: dim + semantic combined styles - val dimOkStyle: Style = Style.create().fg(ok).dim() - val dimWarnStyle: Style = Style.create().fg(warn).dim() - val dimBadStyle: Style = Style.create().fg(bad).dim() - val dimAccentStyle: Style = Style.create().fg(accent).dim() - - // ── Typography ──────────────────────────────────────────── - /** - * Nominal font size (points). Terminal-emulator controlled — this is a - * documentation / scaling reference for layout constants. Adjust if the - * emulator is configured to a size outside the 14-15pt range. - */ - const val fontSize: Int = 14 - - // ── Event category → color mapping ──────────────────────── - /** - * Maps an event category string to its designated color. - * - * | Category | Color | Hex | - * |------------|---------|-----------| - * | Lifecycle | accent2 | `#7aa2f7` | - * | Context | purple | `#c98fd9` | - * | Inference | accent | `#4a9eff` | - * | Tool | ok | `#7fd88f` | - * | Domain | amber | `#e8b765` | - * | Approval | warn | `#e8c06a` | - */ - fun categoryColor(category: String): Color { - val c = category.lowercase() - return when { - c == "lifecycle" -> accent2 - c == "context" -> Color.rgb(201, 143, 217) // purple - c.contains("infer") -> accent // inference / inference - c == "tool" -> ok - c == "domain" -> Color.rgb(232, 183, 101) // amber - c.contains("approval") || c == "approval" -> warn - else -> dim - } - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WelcomePanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WelcomePanel.kt deleted file mode 100644 index 55bc17be..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WelcomePanel.kt +++ /dev/null @@ -1,104 +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 RECENT_MAX = 5 -private const val NAME_MAX = 20 - -/** - * Right-column panel in IDLE state showing welcome / quick-start info and recent sessions. - */ -fun welcomePanelWidget(state: TuiState): Paragraph { - val lines = buildList { - // Brand header with accented x - add(Line.from( - Span.styled("Corre", Theme.fgStrongStyle), - Span.styled("x", Theme.accentStyle), - Span.styled(" Agent Harness", Theme.fgStrongStyle), - )) - - // Connection status - val connText = if (state.connection.connected) { - "Connected \u00B7 ${state.sessions.sessions.size} sessions" - } else { - "Disconnected" - } - add(Line.from(Span.styled(connText, if (state.connection.connected) Theme.dimStyle else Theme.dimBadStyle))) - add(Line.from(Span.styled("", Theme.dimStyle))) - - // Prompt text - add(Line.from(Span.styled("Select a session from the left", Theme.dimStyle))) - add(Line.from(Span.styled("or type a name to start a new one.", Theme.dimStyle))) - add(Line.from(Span.styled("", Theme.dimStyle))) - - // Recent sessions - val sorted = state.sessions.sessions - .sortedByDescending { it.lastEventAt } - .take(RECENT_MAX) - - if (sorted.isNotEmpty()) { - add(Line.from(Span.styled("recent:", Theme.dimStyle))) - for (s in sorted) { - val glyph = statusGlyph(s.status) - val name = s.name.ifEmpty { s.workflowId } - val truncated = if (name.length > NAME_MAX) name.take(NAME_MAX - 1) + "\u2026" else name - val time = formatTimestamp(s.lastEventAt) - add(Line.from( - Span.raw(" "), - Span.styled(truncated, Style.create().fg(Theme.fg)), - Span.raw(" "), - Span.styled(time, Theme.dimStyle), - Span.raw(" "), - Span.styled(glyph, glyphStyle(s.status)), - )) - } - } - } - - val block = Block.builder() - .title("welcome") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - - return Paragraph.builder() - .text(Text.from(lines)) - .block(block) - .build() -} - -/** Returns a one-character status glyph for the given session status. */ -private fun statusGlyph(status: String): String = when (status.lowercase()) { - "active", "running" -> "\u25B8" // ▸ - "completed", "done" -> "\u2714" // ✔ - "failed", "error" -> "\u2718" // ✘ - "paused" -> "\u23F8" // ⏸ - else -> "\u00B7" // · -} - -/** Returns a style for the status glyph. */ -private fun glyphStyle(status: String) = when (status.lowercase()) { - "active", "running" -> Theme.accentStyle - "completed", "done" -> Theme.okStyle - "failed", "error" -> Theme.badStyle - "paused" -> Theme.warnStyle - else -> Theme.dimStyle -} - -/** Formats a Unix-millis timestamp to a short time string like "14:02". */ -private fun formatTimestamp(epochMs: Long): String { - if (epochMs <= 0) return "--:--" - val totalMinutes = (epochMs / 60000) % (24 * 60) - val hh = totalMinutes / 60 - val mm = totalMinutes % 60 - return "${hh.toInt().toString().padStart(2, '0')}:${mm.toInt().toString().padStart(2, '0')}" -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt deleted file mode 100644 index 87ee36bb..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.correx.apps.tui.components - -import com.correx.apps.tui.state.TuiState -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 MAX_VISIBLE = 8 - -fun workflowListWidget(state: TuiState): Paragraph { - val workflows = state.sessions.workflows - val visible = workflows.take(MAX_VISIBLE) - val overflow = workflows.size - MAX_VISIBLE - - val lines = buildList { - if (workflows.isEmpty()) { - add(Line.from(Span.styled("(no workflows configured)", Theme.dimStyle))) - } else { - for ((i, wf) in visible.withIndex()) { - val isSelected = i == state.sessions.selectedWorkflowIndex - val prefix = if (isSelected) Span.styled("▶ ", Theme.accentStyle) else Span.raw(" ") - val name = Span.styled(wf.workflowId, Theme.accentStyle) - val desc = if (wf.description.isNotBlank() && wf.description != wf.workflowId) { - Span.styled(" · ${wf.description}", Theme.dimStyle) - } else { - Span.styled("", Theme.dimStyle) - } - add(Line.from(prefix, name, desc)) - } - if (overflow > 0) { - add(Line.from(Span.styled("— $overflow more —", Theme.dimStyle))) - } - } - } - - val block = Block.builder() - .title("workflows") - .borders(Borders.ALL) - .borderType(BorderType.ROUNDED) - .build() - - return Paragraph.builder().text(Text.from(lines)).block(block).build() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt deleted file mode 100644 index 6bfae68f..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.correx.apps.tui.input - -import com.correx.apps.server.protocol.ServerMessage - -sealed interface Action { - data object Quit : Action - data object OpenNewSessionPrompt : Action - data object CancelSelectedSession : Action - data object ApproveActive : Action - data object RejectActive : Action - data object NavigateUp : Action - data object NavigateDown : Action - data object OpenFilter : Action - data class AppendChar(val ch: Char) : Action - data object Backspace : Action - data object SubmitInput : Action - data object CancelInput : Action - data object ToggleEventStrip : Action - data object SubmitApprovalDecision : Action - data object ReturnToSessionList : Action - data object ShowPendingApproval : Action - data class ServerEventReceived(val message: ServerMessage) : Action - data object Connected : Action - data object Disconnected : Action - data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : Action - data object CycleMode : Action - data object CursorLeft : Action - data object CursorRight : Action - data object ToggleWorkflows : Action - data object CycleChatMode : Action - data object ToggleDiffExpand : Action - data object NoOp : Action -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt deleted file mode 100644 index f2e833d8..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.correx.apps.tui.input - -import com.correx.apps.tui.KeyEvent -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.InputMode - -object KeyResolver { - fun resolve( - key: KeyEvent, - 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 - KeyEvent.ToggleSteeringMode -> Action.CycleChatMode - else -> null - } - if (global != null) return global - - // Cursor keys work everywhere text input is active - if (key is KeyEvent.CursorLeft) return Action.CursorLeft - if (key is KeyEvent.CursorRight) return Action.CursorRight - - // FILTER mode override: ↑↓ always navigates sessions list - if (inputMode == InputMode.FILTER) { - return when (key) { - KeyEvent.NavUp -> Action.NavigateUp - KeyEvent.NavDown -> Action.NavigateDown - KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput - KeyEvent.Escape -> Action.CancelInput - KeyEvent.Tab -> Action.CycleMode - KeyEvent.Backspace -> Action.Backspace - is KeyEvent.CharInput -> Action.AppendChar(key.ch) - else -> null - } - } - - // Display-state-aware routing (ROUTER mode) - return when (displayState) { - DisplayState.IDLE -> when (key) { - KeyEvent.NavUp -> Action.NavigateUp - KeyEvent.NavDown -> Action.NavigateDown - KeyEvent.Enter -> Action.SubmitInput - KeyEvent.Tab -> Action.CycleMode - KeyEvent.Backspace -> Action.Backspace - KeyEvent.NewSession -> Action.OpenNewSessionPrompt - KeyEvent.ToggleWorkflows -> Action.ToggleWorkflows - 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.Cancel -> Action.CancelSelectedSession - KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList - KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip - KeyEvent.ShowPendingApproval -> if (hasPendingApproval) Action.ShowPendingApproval else null - else -> null - } - - DisplayState.APPROVAL -> when (key) { - KeyEvent.Enter -> Action.SubmitApprovalDecision - KeyEvent.Escape -> Action.CancelInput - KeyEvent.Cancel -> Action.CancelInput - KeyEvent.NavUp -> Action.NavigateUp - KeyEvent.NavDown -> Action.NavigateDown - KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList - KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip - KeyEvent.ToggleDiffExpand -> Action.ToggleDiffExpand - KeyEvent.Tab -> Action.CycleMode - KeyEvent.Backspace -> Action.Backspace - is KeyEvent.CharInput -> Action.AppendChar(key.ch) - else -> null - } - } - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt deleted file mode 100644 index 71ef85d2..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.correx.apps.tui.input - -import com.correx.apps.tui.KeyEvent -import dev.tamboui.tui.event.KeyCode -import org.slf4j.LoggerFactory -import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent - -private val log = LoggerFactory.getLogger("com.correx.apps.tui.input") - -@Suppress("CyclomaticComplexMethod", "ReturnCount") -fun mapKey(event: TambouiKeyEvent): KeyEvent? { - log.debug("got event: {}", event) - if (event.hasAlt()) { - return null - } - if (event.hasCtrl()) { - return when { - event.isChar('q') -> KeyEvent.Quit - event.isChar('n') -> KeyEvent.NewSession - event.isChar('c') -> KeyEvent.Cancel - event.isChar('a') -> KeyEvent.Approve - event.isChar('r') -> KeyEvent.Reject - event.isChar('l') -> KeyEvent.ReturnToSessionList - event.isChar('e') -> KeyEvent.ToggleEventStrip - event.isChar('h') -> KeyEvent.ShowPendingApproval - event.isChar('w') -> KeyEvent.ToggleWorkflows - event.isChar('s') -> KeyEvent.ToggleSteeringMode - event.isChar('x') -> KeyEvent.ToggleDiffExpand - else -> null - } - } - return when (event.code()) { - KeyCode.UP -> KeyEvent.NavUp - KeyCode.DOWN -> KeyEvent.NavDown - KeyCode.LEFT -> KeyEvent.CursorLeft - KeyCode.RIGHT -> KeyEvent.CursorRight - KeyCode.ENTER -> KeyEvent.Enter - KeyCode.ESCAPE -> KeyEvent.Escape - KeyCode.BACKSPACE -> KeyEvent.Backspace - KeyCode.TAB -> KeyEvent.Tab - KeyCode.CHAR -> { - val ch = event.character() - if (ch.isISOControl()) null else KeyEvent.CharInput(ch) - } - - else -> null - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ConnectionReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ConnectionReducer.kt deleted file mode 100644 index 6422acb8..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ConnectionReducer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.ConnectionState - -object ConnectionReducer { - fun reduce( - connection: ConnectionState, - action: Action, - ): Pair> = when (action) { - is Action.Connected -> - ConnectionState(connected = true, reconnecting = false, attempt = 0, nextRetryAtMs = null) to emptyList() - is Action.Disconnected -> connection.copy(connected = false, reconnecting = true) to emptyList() - is Action.RetryScheduled -> connection.copy( - reconnecting = true, - attempt = action.attempt, - nextRetryAtMs = action.nextRetryAtMs, - ) to emptyList() - else -> connection to emptyList() - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt deleted file mode 100644 index e46daaec..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ClientMessage - -sealed interface Effect { - data class SendWs(val message: ClientMessage) : Effect - data object Quit : Effect -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt deleted file mode 100644 index 5d132850..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.tui.ws.TuiWsClient - -class EffectDispatcher( - private val wsClient: TuiWsClient, - private val onQuit: () -> Unit, -) { - suspend fun dispatch(effect: Effect) { - when (effect) { - is Effect.SendWs -> wsClient.send(effect.message) - Effect.Quit -> onQuit() - } - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt deleted file mode 100644 index 3cb13ab7..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt +++ /dev/null @@ -1,123 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ApprovalDecision -import com.correx.apps.server.protocol.ClientMessage -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.InputMode -import com.correx.apps.tui.state.TuiState -import com.correx.apps.tui.state.selectedPendingApproval -import com.correx.core.events.types.ApprovalRequestId -import com.correx.core.router.ChatMode - -object InputReducer { - @Suppress("CyclomaticComplexMethod") - fun reduce( - state: TuiState, - action: Action, - ): Pair> = when (action) { - is Action.CycleMode -> state.copy( - inputMode = when (state.inputMode) { - InputMode.ROUTER -> InputMode.FILTER - InputMode.FILTER -> InputMode.ROUTER - }, - ) to emptyList() - - is Action.CycleChatMode -> state.copy( - chatMode = when (state.chatMode) { - ChatMode.CHAT -> ChatMode.STEERING - ChatMode.STEERING -> ChatMode.CHAT - }, - ) to emptyList() - - is Action.CursorLeft -> state.copy( - inputCursor = (state.inputCursor - 1).coerceAtLeast(0), - ) to emptyList() - - is Action.CursorRight -> state.copy( - inputCursor = (state.inputCursor + 1).coerceAtMost(state.inputBuffer.length), - ) to emptyList() - - is Action.OpenNewSessionPrompt -> state.copy( - inputMode = InputMode.ROUTER, - inputBuffer = "", - inputCursor = 0, - ) to emptyList() - - is Action.AppendChar -> { - val buf = state.inputBuffer - val cur = state.inputCursor - state.copy( - inputBuffer = buf.take(cur) + action.ch + buf.drop(cur), - inputCursor = cur + 1, - ) to emptyList() - } - - is Action.Backspace -> { - val cur = state.inputCursor - if (cur > 0) { - val buf = state.inputBuffer - state.copy( - inputBuffer = buf.take(cur - 1) + buf.drop(cur), - inputCursor = cur - 1, - ) to emptyList() - } else { - state to emptyList() - } - } - - is Action.CancelInput -> state.copy( - inputMode = InputMode.ROUTER, - inputBuffer = "", - inputCursor = 0, - ) to emptyList() - - is Action.SubmitInput -> state.copy( - inputBuffer = "", - inputCursor = 0, - ) to emptyList() - - is Action.SubmitApprovalDecision -> { - val decision = state.pendingDecision ?: ApprovalDecision.REJECT - val active = state.selectedPendingApproval() - val steeringNote = state.inputBuffer.ifBlank { null } - if (active != null) { - val selectedId = state.sessions.selectedId - val clearedSessions = state.sessions.copy( - sessions = state.sessions.sessions.map { s -> - if (s.id == selectedId) s.copy(pendingApproval = null) else s - }, - ) - state.copy( - pendingDecision = null, - approvalDismissed = false, - inputBuffer = "", - sessions = clearedSessions, - ) to listOf( - Effect.SendWs( - ClientMessage.ApprovalResponse( - requestId = ApprovalRequestId(active.requestId), - decision = decision, - steeringNote = steeringNote, - ), - ), - ) - } else { - state.copy( - pendingDecision = null, - approvalDismissed = false, - inputBuffer = "", - ) to emptyList() - } - } - - is Action.ShowPendingApproval -> state.copy( - approvalDismissed = false, - ) to emptyList() - - is Action.ReturnToSessionList -> state.copy( - sessions = state.sessions.copy(selectedId = null), - ) to emptyList() - - else -> state to emptyList() - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ProviderReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ProviderReducer.kt deleted file mode 100644 index 16e27983..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ProviderReducer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ServerMessage -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.ProviderState - -object ProviderReducer { - fun reduce( - provider: ProviderState, - action: Action, - ): Pair> = when (action) { - is Action.ServerEventReceived -> when (val msg = action.message) { - is ServerMessage.ProviderStatusChanged -> ProviderState( - id = msg.status.providerId, - status = msg.status.status, - ) to emptyList() - else -> provider to emptyList() - } - else -> provider to emptyList() - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt deleted file mode 100644 index 8b8750d7..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt +++ /dev/null @@ -1,294 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ApprovalDecision -import com.correx.apps.server.protocol.ServerMessage -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.InputMode -import com.correx.apps.tui.state.ProviderType -import com.correx.apps.tui.state.RouterEntry -import com.correx.apps.tui.state.TuiState -import com.correx.apps.tui.state.displayState -import org.slf4j.LoggerFactory - -private val log = LoggerFactory.getLogger(RootReducer::class.java.name) - -object RootReducer { - fun reduce( - state: TuiState, - action: Action, - clock: () -> Long = System::currentTimeMillis, - ): Pair> { - val phase = SnapshotPhaseReducer.process(state, action) - var current = phase.state - val allEffects = mutableListOf() - val replayingBufferedEvents = action is Action.ServerEventReceived && - action.message is ServerMessage.SnapshotComplete - for (a in phase.actionsToDispatch) { - if (replayingBufferedEvents) { - val inner = SnapshotPhaseReducer.process(current, a) - current = inner.state - for (ia in inner.actionsToDispatch) { - val (next, effects) = reduceThroughSubReducers(current, ia, clock) - current = next - allEffects += effects - } - } else { - val (next, effects) = reduceThroughSubReducers(current, a, clock) - current = next - allEffects += effects - } - } - return current to allEffects - } - - @Suppress("CyclomaticComplexMethod") - private fun reduceThroughSubReducers( - state: TuiState, - action: Action, - clock: () -> Long, - ): Pair> { - log.debug("action={}", action::class.simpleName) - // Effect.Quit appended LAST so all cleanup effects from sub-reducers dispatch first. - // See effects-01 (sequential dispatch) for why list position is a runtime guarantee. - // I-Q3: sub-reducers must not synthesize Effect.Quit — it is a terminal signal owned by RootReducer. - - // Capture pre-reducer state for cross-reducer weaving. - val prevInputMode = state.inputMode - val prevInputBuffer = state.inputBuffer - val prevSelectedId = state.sessions.selectedId - - log.debug("input state before reducers={}", state.input) - val (afterInput, ie) = InputReducer.reduce(state, action) - - log.debug("sessions state before reducers={}", state.sessions) - val (sessions, se) = SessionsReducer.reduce( - SessionsReducerContext( - sessions = afterInput.sessions, - displayState = afterInput.displayState, - inputMode = prevInputMode, - inputText = prevInputBuffer, - action = action, - clock = clock, - chatMode = afterInput.chatMode, - ), - ) - log.debug("connection state before reducers={}", state.connection) - val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action) - log.debug("provider state before reducers={}", state.provider) - val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action) - - // ── Cross-reducer state weaving ── - // After all sub-reducers have run, weave fields that span multiple reducer boundaries. - // InputReducer runs first (clears inputBuffer, mode), then SessionsReducer (applies filter, - // sends effects), then ConnectionReducer and ProviderReducer. - - // 1. Base merge: overlay sub-reducer outputs onto state after InputReducer. - val withSubReducers = afterInput.copy( - sessions = sessions, - connection = connection, - provider = provider, - ) - - // 2. backgroundUpdateCount reset: on any selectedId change (ARCH-BADGE-2). - val withBgReset = if (sessions.selectedId != prevSelectedId) { - withSubReducers.copy(sessions = sessions.copy(backgroundUpdateCount = 0)) - } else { - withSubReducers - } - - // 3. Action-specific cross-field handles. - val final = when (action) { - is Action.ApproveActive -> withBgReset.copy( - pendingDecision = ApprovalDecision.APPROVE, - ) - - is Action.RejectActive -> withBgReset.copy( - pendingDecision = ApprovalDecision.REJECT, - ) - - is Action.CancelInput -> when { - prevInputMode == InputMode.FILTER -> withBgReset - else -> withBgReset.copy(approvalDismissed = true, sessionEntered = false) - } - - is Action.ReturnToSessionList -> withBgReset.copy(sessionEntered = false) - - is Action.ToggleEventStrip -> { - if (withBgReset.displayState == DisplayState.IDLE) withBgReset - else withBgReset.copy(eventStripVisible = !withBgReset.eventStripVisible) - } - - is Action.ToggleDiffExpand -> withBgReset.copy( - diffExpanded = !withBgReset.diffExpanded, - diffScrollOffset = 0, - ) - - // Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1), - // or enter a selected session from the list in IDLE. - is Action.SubmitInput -> { - val text = prevInputBuffer.trim() - when { - text.isBlank() && sessions.selectedId != null && prevInputMode != InputMode.FILTER -> - withBgReset.copy(sessionEntered = true) - - 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), - routerMessages = withBgReset.routerMessages.run { - this + (selectedId to (this[selectedId].orEmpty() + RouterEntry("user", text))) - }, - ) - } - - // Optimistic session created by SessionsReducer for IDLE chat submit. - sessions.selectedId != null && prevInputMode == InputMode.ROUTER && - withBgReset.displayState == DisplayState.IDLE -> { - val selectedId = sessions.selectedId!! - withBgReset.copy( - sessionEntered = true, - routerMessages = withBgReset.routerMessages.run { - this + (selectedId to (this[selectedId].orEmpty() + RouterEntry("user", text))) - }, - ) - } - - else -> withBgReset - } - } - - // Auto-enter a newly started session when SessionStarted arrives. - // When a ProviderStatusChanged arrives, update model/provider labels. - is Action.ServerEventReceived -> { - val msg = action.message - when { - msg is ServerMessage.SessionStarted -> { - withBgReset.copy( - sessionEntered = true, - sessions = withBgReset.sessions.copy(selectedId = msg.sessionId.value), - ) - } - msg is ServerMessage.ProviderStatusChanged -> { - withBgReset.copy( - currentModel = msg.providerId, - providerType = if (msg.providerId.contains("llama", ignoreCase = true) || - msg.providerId.contains("local", ignoreCase = true) - ) ProviderType.LOCAL else ProviderType.REMOTE, - ) - } - msg is ServerMessage.RouterResponseMessage -> { - withBgReset.copy( - routerConnected = true, - routerMessages = withBgReset.routerMessages.run { - val key = msg.sessionId.value - this + (key to (this[key].orEmpty() + RouterEntry("router", msg.content))) - }, - ) - } - msg is ServerMessage.ToolCompleted && msg.diff != null -> { - withBgReset.copy( - routerMessages = withBgReset.routerMessages.run { - val key = msg.sessionId.value - this + (key to (this[key].orEmpty() + RouterEntry("tool", msg.diff!!))) - }, - ) - } - else -> withBgReset - } - } - - // Diff scrolling in APPROVAL mode when expanded. - is Action.NavigateUp -> { - if (withBgReset.displayState == DisplayState.APPROVAL && withBgReset.diffExpanded) { - // TODO(diff-scroll): cap scrollOffset at totalLines - PREVIEW_MAX_LINES to prevent - // scrolling past the end of the diff. Needs total line count in state or a clamp - // communicated from ApprovalSurface. - withBgReset.copy(diffScrollOffset = maxOf(0, withBgReset.diffScrollOffset - 1)) - } else 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 -> { - val text = history[history.size - 1] - withBgReset.copy( - savedInputBuffer = withBgReset.inputBuffer, - inputHistoryIndex = history.size - 1, - inputBuffer = text, - inputCursor = text.length, - ) - } - 0 -> withBgReset // at oldest entry, stay - else -> { - val newIndex = withBgReset.inputHistoryIndex - 1 - val text = history[newIndex] - withBgReset.copy( - inputHistoryIndex = newIndex, - inputBuffer = text, - inputCursor = text.length, - ) - } - } - } else { - withBgReset - } - } else { - withBgReset - } - } else { - withBgReset - } - } - - // Diff scrolling in APPROVAL mode when expanded. - is Action.NavigateDown -> { - if (withBgReset.displayState == DisplayState.APPROVAL && withBgReset.diffExpanded) { - // TODO(diff-scroll): cap scrollOffset at totalLines - PREVIEW_MAX_LINES; same issue - // as NavigateUp above — the reducer doesn't know the diff length. - withBgReset.copy(diffScrollOffset = withBgReset.diffScrollOffset + 1) - } else 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 -> { - val saved = withBgReset.savedInputBuffer - withBgReset.copy( - inputHistoryIndex = -1, - inputBuffer = saved, - inputCursor = saved.length, - ) - } - else -> { - val newIndex = withBgReset.inputHistoryIndex + 1 - val text = history[newIndex] - withBgReset.copy( - inputHistoryIndex = newIndex, - inputBuffer = text, - inputCursor = text.length, - ) - } - } - } else { - withBgReset - } - } else { - withBgReset - } - } - - else -> withBgReset - } - - val combined = ie + se + ce + pe - val withTerminal = if (action is Action.Quit) combined + Effect.Quit else combined - return final to withTerminal - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ServerMessageReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ServerMessageReducer.kt deleted file mode 100644 index 9ed7e118..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/ServerMessageReducer.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ServerMessage -import com.correx.apps.tui.input.Action - -/** - * Thin adapter that wraps a ServerMessage in ServerEventReceived and fans it through the root reducer. - * Retained so existing callers that were using StateReducer.kt logic have a home during the transition. - */ -internal fun serverMessageToAction(msg: ServerMessage): Action = Action.ServerEventReceived(msg) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt deleted file mode 100644 index 99a96dce..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt +++ /dev/null @@ -1,661 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ClientMessage -import com.correx.apps.server.protocol.PauseReason -import com.correx.apps.server.protocol.ServerMessage -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.ApprovalInfo -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.InputMode -import com.correx.apps.tui.state.SessionSummary -import com.correx.apps.tui.state.SessionsState -import com.correx.apps.tui.state.ToolDisplayStatus -import com.correx.apps.tui.state.ToolManifestEntry -import com.correx.apps.tui.state.TuiEventEntry -import com.correx.apps.tui.state.TuiToolRecord -import com.correx.core.events.types.SessionId -import com.correx.core.router.ChatMode -import org.slf4j.LoggerFactory -import java.time.Instant -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter - -private val log = LoggerFactory.getLogger(SessionsReducer::class.simpleName) - -data class SessionsReducerContext( - val sessions: SessionsState, - val displayState: DisplayState, - val inputMode: InputMode, - val inputText: String, - val action: Action, - val clock: () -> Long = System::currentTimeMillis, - val chatMode: ChatMode = ChatMode.CHAT, -) - -object SessionsReducer { - private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneOffset.UTC) - - fun reduce(ctx: SessionsReducerContext): Pair> = when (ctx.action) { - is Action.NavigateUp -> when { - ctx.inputMode == InputMode.FILTER -> navigateUp(ctx.sessions) to emptyList() - ctx.displayState == DisplayState.IDLE -> navigateUp(ctx.sessions) to emptyList() - ctx.displayState == DisplayState.IN_SESSION -> ctx.sessions to emptyList() - else -> ctx.sessions to emptyList() - } - - is Action.NavigateDown -> when { - ctx.inputMode == InputMode.FILTER -> navigateDown(ctx.sessions) to emptyList() - ctx.displayState == DisplayState.IDLE -> navigateDown(ctx.sessions) to emptyList() - ctx.displayState == DisplayState.IN_SESSION -> ctx.sessions to emptyList() - else -> ctx.sessions to emptyList() - } - - is Action.SubmitInput -> when { - ctx.inputMode == InputMode.FILTER -> ctx.sessions.copy(filter = ctx.inputText) to emptyList() - ctx.displayState == DisplayState.IDLE -> { - val text = ctx.inputText.trim() - val wfIndex = ctx.sessions.selectedWorkflowIndex - when { - wfIndex in ctx.sessions.workflows.indices -> { - val wf = ctx.sessions.workflows[wfIndex] - ctx.sessions.copy(selectedWorkflowIndex = -1) to listOf( - Effect.SendWs(ClientMessage.StartSession(workflowId = wf.workflowId, config = null)), - ) - } - - text.isNotBlank() -> { - val sessionId = SessionId(java.util.UUID.randomUUID().toString()) - val optimistic = SessionSummary( - id = sessionId.value, - status = "STARTING", - workflowId = "chat", - name = "chat", - lastEventAt = ctx.clock(), - ) - ctx.sessions.copy( - sessions = ctx.sessions.sessions + optimistic, - selectedId = sessionId.value, - ) to listOf( - Effect.SendWs(ClientMessage.StartChatSession(sessionId = sessionId, text = text)), - ) - } - - else -> ctx.sessions to emptyList() - } - } - - ctx.displayState == DisplayState.IN_SESSION -> ctx.sessions.selectedId?.let { sid -> - ctx.sessions to listOf( - Effect.SendWs( - ClientMessage.ChatInput( - sessionId = SessionId(sid), - text = ctx.inputText, - mode = ctx.chatMode, - ), - ), - ) - } ?: (ctx.sessions to emptyList()) - - else -> ctx.sessions to emptyList() - } - - is Action.CancelInput -> if (ctx.inputMode == InputMode.FILTER) { - ctx.sessions.copy(filter = "") to emptyList() - } else { - ctx.sessions to emptyList() - } - - is Action.CancelSelectedSession -> { - val id = ctx.sessions.selectedId - if (id != null) { - ctx.sessions to listOf(Effect.SendWs(ClientMessage.CancelSession(sessionId = SessionId(id)))) - } else { - ctx.sessions to emptyList() - } - } - - is Action.ToggleWorkflows -> ctx.sessions.copy( - workflowsVisible = !ctx.sessions.workflowsVisible, - ) to emptyList() - - is Action.ServerEventReceived -> applyServerMessage(ctx.sessions, ctx.action.message, ctx.clock) - else -> ctx.sessions to emptyList() - } - - private fun filteredSessions(sessions: SessionsState): List = - if (sessions.filter.isBlank()) sessions.sessions - else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) } - - private fun navigateUp(sessions: SessionsState): SessionsState { - val wfi = sessions.selectedWorkflowIndex - if (wfi >= 0) { - // Currently in the workflow picker - return if (wfi > 0) { - sessions.copy(selectedWorkflowIndex = wfi - 1) - } else { - // Move back to last session, or wrap to last workflow if no sessions - val list = filteredSessions(sessions) - if (list.isNotEmpty()) { - sessions.copy(selectedWorkflowIndex = -1, selectedId = list.last().id) - } else { - sessions.copy(selectedWorkflowIndex = sessions.workflows.lastIndex) - } - } - } - val list = filteredSessions(sessions) - if (list.isEmpty()) { - // No sessions — move to last workflow - return if (sessions.workflows.isNotEmpty()) { - sessions.copy(selectedWorkflowIndex = sessions.workflows.lastIndex) - } else { - sessions - } - } - val idx = list.indexOfFirst { it.id == sessions.selectedId } - val newIdx = if (idx <= 0) list.lastIndex else idx - 1 - return sessions.copy(selectedId = list[newIdx].id, selectedWorkflowIndex = -1) - } - - private fun navigateDown(sessions: SessionsState): SessionsState { - val list = filteredSessions(sessions) - if (list.isEmpty()) { - // No sessions — navigate workflows - val wfi = sessions.selectedWorkflowIndex - return if (sessions.workflows.isNotEmpty()) { - val next = if (wfi >= sessions.workflows.lastIndex) 0 else wfi + 1 - sessions.copy(selectedWorkflowIndex = next) - } else { - sessions - } - } - val idx = list.indexOfFirst { it.id == sessions.selectedId } - if (idx >= list.lastIndex) { - // At bottom of sessions — move to first workflow - return if (sessions.workflows.isNotEmpty()) { - sessions.copy(selectedId = null, selectedWorkflowIndex = 0) - } else { - sessions.copy(selectedId = list[0].id) - } - } - val newIdx = idx + 1 - return sessions.copy(selectedId = list[newIdx].id, selectedWorkflowIndex = -1) - } - - private fun formatTime(epochMs: Long): String = - timeFormatter.format(Instant.ofEpochMilli(epochMs)) - - @Suppress("CyclomaticComplexMethod") - private fun applyServerMessage( - sessions: SessionsState, - msg: ServerMessage, - clock: () -> Long, - ): Pair> { - val (result, effects) = when (msg) { - is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions) - is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock) - is ServerMessage.SessionResumed -> processSessionResumedMessage(msg, sessions, clock) - is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock) - is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock) - is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions) - is ServerMessage.StageCompleted -> processStageCompletedMessage(msg, sessions) - is ServerMessage.StageFailed -> processStageFailedMessage(msg, sessions) - is ServerMessage.ToolStarted -> processToolStartedMessage(clock, sessions, msg) - is ServerMessage.InferenceCompleted -> processInferenceCompletedMessage(sessions, msg) - is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions) - is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions) - is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg) - is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg) - is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg) - is ServerMessage.StageToolManifest -> processStageToolManifestMessage(sessions, msg) - is ServerMessage.ArtifactCreated -> processArtifactCreatedMessage(sessions, msg) - is ServerMessage.WorkflowList -> processWorkflowListMessage(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.SessionResumed -> 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 - is ServerMessage.ArtifactCreated -> msg.sessionId.value - // Control/non-event messages do not count per ARCH-BADGE-1: - is ServerMessage.StageToolManifest -> null - is ServerMessage.SnapshotComplete -> null - is ServerMessage.ProtocolError -> null - is ServerMessage.ProviderStatusChanged -> null - else -> null - } - - private fun incrementBackgroundCountIfNonSelected( - sessions: SessionsState, - msg: ServerMessage, - ): SessionsState { - val sessionId = sessionIdFromMessage(msg) ?: return sessions - return if (sessionId != sessions.selectedId) { - sessions.copy(backgroundUpdateCount = sessions.backgroundUpdateCount + 1) - } else { - sessions - } - } - - /** TODO(RF-3): If StageToolManifest arrives before SessionStarted, the session - * won't exist in the list and the manifest is silently dropped. Options: - * (a) Buffer the manifest and apply when SessionStarted arrives for this sessionId. - * (b) Create a placeholder SessionSummary from the manifest's sessionId/workflowId. - * Option (b) also requires dedup in processSessionStartedMessage to avoid duplicates. - * Deferred — the spec says manifest arrives on session start, but WebSocket ordering - * is not guaranteed. */ - private fun processStageToolManifestMessage( - sessions: SessionsState, - msg: ServerMessage.StageToolManifest, - ): Pair> { - val toolsByStage = msg.stages.associate { stageDecl -> - stageDecl.stageId to stageDecl.tools.map { toolDecl -> - ToolManifestEntry(name = toolDecl.name, tier = toolDecl.tier) - } - } - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - s.copy(toolsByStage = toolsByStage) - } else { - s - } - }, - ) to emptyList() - } - - private fun processSessionSnapshotMessage( - clock: () -> Long, - sessionState: SessionsState, - msg: ServerMessage.SessionSnapshot, - ): Pair> { - val firstPending = msg.pendingApprovals.firstOrNull() - val approvalInfo = firstPending?.let { - ApprovalInfo( - requestId = it.requestId, - sessionId = msg.sessionId.value, - tier = it.tier, - riskSummary = "unknown", - toolName = it.toolName, - preview = it.preview, - ) - } - val status = if (firstPending != null) "PAUSED awaiting approval" else msg.state.status - val eventEntries = msg.recentEvents.map { entry -> - TuiEventEntry( - timestamp = formatTime(entry.timestamp), - type = entry.type, - detail = entry.detail, - ) - } - val tools = msg.tools.map { tool -> - val displayStatus = when (tool.status) { - "COMPLETED" -> ToolDisplayStatus.COMPLETED - "FAILED" -> ToolDisplayStatus.FAILED - "REJECTED" -> ToolDisplayStatus.REJECTED - else -> ToolDisplayStatus.STARTED - } - TuiToolRecord( - name = tool.name, - tier = tool.tier, - status = displayStatus, - argsPreview = null, - ) - } - val summary = SessionSummary( - id = msg.sessionId.value, - status = status, - workflowId = msg.workflowId, - name = msg.workflowId, - lastEventAt = clock(), - currentStage = msg.state.currentStageId, - currentStageId = msg.state.currentStageId, - pendingApproval = approvalInfo, - recentEvents = eventEntries, - tools = tools, - ) - val selected = sessionState.selectedId ?: msg.sessionId.value - return sessionState.copy( - sessions = sessionState.sessions + summary, - selectedId = selected, - ) to emptyList() - } - - private fun processApprovalRequiredMessage( - sessions: SessionsState, - msg: ServerMessage.ApprovalRequired, - ): Pair> { - val info = ApprovalInfo( - requestId = msg.requestId.value, - sessionId = msg.sessionId.value, - tier = msg.tier.name, - riskSummary = msg.riskSummary.level, - toolName = msg.toolName, - preview = msg.preview, - ) - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) s.copy(pendingApproval = info) else s - }, - ) to emptyList() - } - - private fun processToolRejectedMessage( - clock: () -> Long, - sessions: SessionsState, - msg: ServerMessage.ToolRejected, - ): Pair> { - val now = clock() - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - val updatedTools = s.tools.map { t -> - if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) { - t.copy(status = ToolDisplayStatus.REJECTED) - } else { - t - } - } - s.copy(tools = updatedTools, lastEventAt = now) - } else { - s - } - }, - ) to emptyList() - } - - private fun processToolFailedMessage( - msg: ServerMessage.ToolFailed, - sessions: SessionsState, - ): Pair> { - val now = msg.occurredAt - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - val updatedTools = s.tools.map { t -> - if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) { - t.copy(status = ToolDisplayStatus.FAILED) - } else { - t - } - } - s.copy(tools = updatedTools, lastEventAt = now) - } else { - s - } - }, - ) to emptyList() - } - - private fun processToolCompletedMessage( - msg: ServerMessage.ToolCompleted, - sessions: SessionsState, - ): Pair> { - val now = msg.occurredAt - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - val updatedTools = s.tools.map { t -> - if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) { - t.copy(status = ToolDisplayStatus.COMPLETED) - } else { - t - } - } - val entry = TuiEventEntry(formatTime(now), "ToolCompleted", msg.toolName) - s.copy( - tools = updatedTools, - lastOutput = "${msg.toolName}: ${msg.outputSummary}", - recentEvents = (s.recentEvents + entry).takeLast(7), - lastEventAt = now, - ) - } else { - s - } - }, - ) to emptyList() - } - - private fun processToolStartedMessage( - clock: () -> Long, - sessions: SessionsState, - msg: ServerMessage.ToolStarted, - ): Pair> { - val now = clock() - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - val record = TuiToolRecord(msg.toolName, msg.tier.level, ToolDisplayStatus.STARTED, null) - s.copy( - tools = (s.tools + record).takeLast(8), - lastEventAt = now, - ) - } else { - s - } - }, - ) to emptyList() - } - - private fun processStageFailedMessage( - msg: ServerMessage.StageFailed, - sessions: SessionsState, - ): Pair> { - val now = msg.occurredAt - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - val entry = TuiEventEntry(formatTime(now), "StageFailed", msg.stageId.value) - s.copy( - currentStage = null, - recentEvents = (s.recentEvents + entry).takeLast(7), - lastEventAt = now, - ) - } else { - s - } - }, - ) to emptyList() - } - - private fun processStageCompletedMessage( - msg: ServerMessage.StageCompleted, - sessions: SessionsState, - ): Pair> { - val now = msg.occurredAt - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - val entry = TuiEventEntry(formatTime(now), "StageCompleted", msg.stageId.value) - s.copy( - currentStage = null, - recentEvents = (s.recentEvents + entry).takeLast(7), - lastEventAt = now, - ) - } else { - s - } - }, - ) to emptyList() - } - - private fun processStageStartedMessage( - msg: ServerMessage.StageStarted, - sessions: SessionsState, - ): Pair> { - val now = msg.occurredAt - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - val entry = TuiEventEntry(formatTime(now), "StageStarted", msg.stageId.value) - s.copy( - currentStage = msg.stageId.value, - currentStageId = msg.stageId.value, - tools = emptyList(), - recentEvents = (s.recentEvents + entry).takeLast(7), - lastEventAt = now, - ) - } else { - s - } - }, - ) to emptyList() - } - - private fun processSessionFailedMessage( - sessions: SessionsState, - msg: ServerMessage.SessionFailed, - clock: () -> Long, - ): Pair> { - val result = touchSession(sessions, msg.sessionId.value, "FAILED", clock) - val cleared = if (msg.sessionId.value == result.selectedId) result.copy(selectedId = null) else result - return cleared to emptyList() - } - - private fun processSessionCompletedMessage( - sessions: SessionsState, - msg: ServerMessage.SessionCompleted, - clock: () -> Long, - ): Pair> { - val result = touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) - return result to emptyList() - } - - private fun processSessionPausedMessage( - msg: ServerMessage.SessionPaused, - sessions: SessionsState, - clock: () -> Long, - ): Pair> { - val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) { - "PAUSED awaiting approval" - } else { - "PAUSED" - } - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s - }, - ) to emptyList() - } - - private fun processSessionResumedMessage( - msg: ServerMessage.SessionResumed, - sessions: SessionsState, - clock: () -> Long, - ): Pair> = sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) s.copy( - status = "ACTIVE", - pendingApproval = null, - lastEventAt = clock(), - ) else s - }, - ) to emptyList() - - private fun processSessionStartedMessage( - msg: ServerMessage.SessionStarted, - clock: () -> Long, - sessions: SessionsState, - ): Pair> { - // Remove any optimistic (STARTING) session created by IDLE submit, then insert the - // real session from the server. This prevents duplicate session entries when the - // server-generated session ID differs from the client-generated optimistic ID. - val hadOptimistic = sessions.sessions.any { it.status == "STARTING" } - val cleaned = sessions.sessions.filter { it.status != "STARTING" } - val summary = SessionSummary( - id = msg.sessionId.value, - status = "ACTIVE", - workflowId = msg.workflowId, - name = msg.workflowId, - lastEventAt = clock(), - currentStage = null, - lastOutput = null, - ) - // When replacing an optimistic session, switch selectedId to the real session ID. - // Otherwise preserve the existing selection (normal SessionStarted for a new session). - val selected = if (hadOptimistic) msg.sessionId.value else (sessions.selectedId ?: msg.sessionId.value) - return sessions.copy( - sessions = cleaned + summary, - selectedId = selected, - ) to emptyList() - } - - private fun processInferenceCompletedMessage( - sessions: SessionsState, - msg: ServerMessage.InferenceCompleted, - ): Pair> { - val now = msg.occurredAt - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - val entry = TuiEventEntry(formatTime(now), "InferenceCompleted", msg.stageId.value) - s.copy( - lastOutput = msg.outputSummary, - lastResponseText = msg.responseText.takeIf { it.isNotEmpty() }, - recentEvents = (s.recentEvents + entry).takeLast(7), - lastEventAt = now, - ) - } else { - s - } - }, - ) to emptyList() - } - - private fun touchSession( - sessions: SessionsState, - sessionId: String, - status: String, - clock: () -> Long, - ): SessionsState = sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == sessionId) s.copy(status = status, lastEventAt = clock()) else s - }, - ) - - private fun processArtifactCreatedMessage( - sessions: SessionsState, - msg: ServerMessage.ArtifactCreated, - ): Pair> { - val now = msg.sequence - val entry = TuiEventEntry( - timestamp = formatTime(now), - type = "ArtifactCreated", - detail = msg.artifactId.value, - ) - return sessions.copy( - sessions = sessions.sessions.map { s -> - if (s.id == msg.sessionId.value) { - s.copy( - recentEvents = (s.recentEvents + entry).takeLast(7), - lastEventAt = now, - ) - } else { - s - } - }, - ) to emptyList() - } - - private fun processWorkflowListMessage( - sessions: SessionsState, - msg: ServerMessage.WorkflowList, - ): Pair> = - sessions.copy(workflows = msg.workflows) to emptyList() -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducer.kt deleted file mode 100644 index 145ea6d5..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducer.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ServerMessage -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.TuiState -import org.slf4j.LoggerFactory - -object SnapshotPhaseReducer { - private val log = LoggerFactory.getLogger(SnapshotPhaseReducer::class.java) - - data class Result(val state: TuiState, val actionsToDispatch: List) - - fun process(state: TuiState, action: Action): Result = when (action) { - is Action.Disconnected -> Result( - state.copy(snapshotPhase = true, pendingEvents = emptyList(), cursors = emptyMap()), - listOf(action), - ) - is Action.ServerEventReceived -> handleServerEvent(state, action) - else -> Result(state, listOf(action)) - } - - private fun handleServerEvent(state: TuiState, action: Action.ServerEventReceived): Result { - val msg = action.message - return when { - msg is ServerMessage.SessionSnapshot -> { - val newCursors = state.cursors + (msg.sessionId.value to msg.lastSessionSequence) - Result(state.copy(cursors = newCursors), listOf(action)) - } - msg is ServerMessage.SnapshotComplete -> { - val replayActions = state.pendingEvents.map { Action.ServerEventReceived(it) } - Result( - state.copy(snapshotPhase = false, pendingEvents = emptyList()), - replayActions, - ) - } - state.snapshotPhase -> { - Result(state.copy(pendingEvents = state.pendingEvents + msg), emptyList()) - } - else -> dedupeLive(state, action, msg) - } - } - - private fun dedupeLive( - state: TuiState, - action: Action.ServerEventReceived, - msg: ServerMessage, - ): Result { - val sid = sessionIdOf(msg) ?: return Result(state, listOf(action)) - val seq = sessionSequenceOf(msg) ?: return Result(state, listOf(action)) - val cursor = state.cursors[sid] ?: 0L - return when { - seq <= cursor -> { - log.debug("dropping duplicate sessionSequence={} for sid={} (cursor={})", seq, sid, cursor) - Result(state, emptyList()) - } - seq > cursor + 1 -> { - log.warn("gap detected for sid={}: cursor={} -> incoming seq={} (applying anyway)", sid, cursor, seq) - Result(state.copy(cursors = state.cursors + (sid to seq)), listOf(action)) - } - else -> Result(state.copy(cursors = state.cursors + (sid to seq)), listOf(action)) - } - } - - internal fun sessionIdOf(msg: ServerMessage): String? = when (msg) { - is ServerMessage.SessionStarted -> msg.sessionId.value - is ServerMessage.SessionPaused -> msg.sessionId.value - is ServerMessage.SessionResumed -> 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.InferenceStarted -> msg.sessionId.value - is ServerMessage.InferenceCompleted -> msg.sessionId.value - is ServerMessage.InferenceTimedOut -> msg.sessionId.value - is ServerMessage.ToolStarted -> 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 - else -> null - } - - internal fun sessionSequenceOf(msg: ServerMessage): Long? = when (msg) { - is ServerMessage.SessionMessage -> msg.sessionSequence - else -> null - } -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ConnectionState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ConnectionState.kt deleted file mode 100644 index dfae851b..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ConnectionState.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.correx.apps.tui.state - -data class ConnectionState( - val connected: Boolean = false, - val reconnecting: Boolean = false, - val attempt: Int = 0, - val nextRetryAtMs: Long? = null, -) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/DisplayState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/DisplayState.kt deleted file mode 100644 index 720964e0..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/DisplayState.kt +++ /dev/null @@ -1,16 +0,0 @@ -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 - sessionEntered -> DisplayState.IN_SESSION - else -> DisplayState.IDLE - } - } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt deleted file mode 100644 index bcb772d2..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.correx.apps.tui.state - -data class InputState( - val mode: InputMode = InputMode.ROUTER, - val text: String = "", -) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ProviderState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ProviderState.kt deleted file mode 100644 index a5ed278e..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/ProviderState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.correx.apps.tui.state - -data class ProviderState( - val id: String = "", - val status: String = "unknown", -) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt deleted file mode 100644 index 1a4b0642..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.correx.apps.tui.state - -import com.correx.apps.server.protocol.WorkflowDto - -data class SessionsState( - val sessions: List = emptyList(), - val selectedId: String? = null, - val filter: String = "", - val backgroundUpdateCount: Int = 0, - val workflows: List = emptyList(), - /** Index into [workflows] when focus is in the workflow picker; -1 = no workflow selected. */ - val selectedWorkflowIndex: Int = -1, - /** When true, the workflow list is shown in IDLE layout even when sessions exist. */ - val workflowsVisible: Boolean = false, -) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt deleted file mode 100644 index edaf00de..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.correx.apps.tui.state - -import com.correx.apps.server.protocol.ApprovalDecision -import com.correx.apps.server.protocol.ServerMessage -import com.correx.core.router.ChatMode - -enum class InputMode { ROUTER, FILTER } - -enum class ToolDisplayStatus { STARTED, COMPLETED, FAILED, REJECTED } - -enum class ProviderType { LOCAL, REMOTE } - -data class TuiToolRecord( - val name: String, - val tier: Int, - val status: ToolDisplayStatus, - val argsPreview: String?, -) - -data class TuiEventEntry( - val timestamp: String, - val type: String, - val detail: String, -) - -/** - * A single entry in the router conversation log. - * @property role one of "user", "router", or "tool" - * @property content the message text - */ -data class RouterEntry( - val role: String, - val content: String, -) - -data class TuiState( - val connection: ConnectionState = ConnectionState(), - val sessions: SessionsState = SessionsState(), - val input: InputState = InputState(), - val provider: ProviderState = ProviderState(), - val inputMode: InputMode = InputMode.ROUTER, - val inputBuffer: String = "", - val inputCursor: Int = 0, - val eventStripVisible: Boolean = true, - val inputHistory: Map> = emptyMap(), - val inputHistoryIndex: Int = -1, - val savedInputBuffer: String = "", - val pendingDecision: ApprovalDecision? = null, - val approvalDismissed: Boolean = false, - /** - * True when the user has explicitly "entered" a session (pressed Enter on it or - * just started a new one). False when simply navigating the session list with ↑↓. - * Drives [displayState]: IN_SESSION requires both [selectedId] and [sessionEntered]. - */ - val sessionEntered: Boolean = false, - val currentModel: String? = null, - val providerType: ProviderType = ProviderType.LOCAL, - val routerConnected: Boolean = false, - val routerMessages: Map> = emptyMap(), - val diffExpanded: Boolean = false, - val diffScrollOffset: Int = 0, - val chatMode: ChatMode = ChatMode.CHAT, - /** - * True from connect until SnapshotComplete observed. Reset to true on Disconnected. - * When true, event-bearing messages are buffered in [pendingEvents]; snapshots are applied immediately. - */ - val snapshotPhase: Boolean = true, - /** - * FIFO buffer of event-bearing server messages received while snapshotPhase == true. - * Drained atomically when snapshot phase completes. - */ - val pendingEvents: List = emptyList(), - /** - * Per-session high-water mark keyed by SessionId.value. Updated only on applied live events. - * Used to deduplicate events during live streaming phase. - */ - val cursors: Map = emptyMap(), - /** Context usage for the active session (populated from server snapshot data). */ - val contextUsed: Int? = null, - /** Context budget for the active session (populated from server snapshot data). */ - val contextBudget: Int? = null, -) - -data class ToolManifestEntry( - val name: String, - val tier: Int, -) - -data class SessionSummary( - val id: String, - val status: String, - val workflowId: String, - val name: String = "", - val lastEventAt: Long, - val currentStage: String? = null, - val currentStageId: String? = null, - val nextStageId: String? = null, - val lastOutput: String? = null, - val lastResponseText: String? = null, - val tools: List = emptyList(), - val toolsByStage: Map> = emptyMap(), - val recentEvents: List = emptyList(), - val pendingApproval: ApprovalInfo? = null, -) - -data class ApprovalInfo( - val requestId: String, - val sessionId: String, - val tier: String, - val riskSummary: String, - val toolName: String?, - val preview: String?, -) - -fun TuiState.selectedPendingApproval(): ApprovalInfo? = - sessions.sessions.find { it.id == sessions.selectedId }?.pendingApproval diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/ConnectionEvent.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/ConnectionEvent.kt deleted file mode 100644 index 834ad5f2..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/ConnectionEvent.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.correx.apps.tui.ws - -sealed interface ConnectionEvent { - data object Connected : ConnectionEvent - data object Disconnected : ConnectionEvent - data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : ConnectionEvent -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt deleted file mode 100644 index 370c2bd6..00000000 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.correx.apps.tui.ws - -import com.correx.apps.server.protocol.ClientMessage -import com.correx.apps.server.protocol.ServerMessage -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.plugins.websocket.WebSockets -import io.ktor.client.plugins.websocket.webSocket -import io.ktor.http.HttpMethod -import io.ktor.websocket.Frame -import io.ktor.websocket.WebSocketSession -import io.ktor.websocket.readText -import io.ktor.websocket.send -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -private const val INITIAL_RETRY_DELAY_MS = 1_000L -private const val MAX_RETRY_DELAY_MS = 30_000L - -class TuiWsClient( - private val host: String, - private val port: Int, -) { - private val json = Json { - classDiscriminator = "type" - ignoreUnknownKeys = true - } - - private val client = HttpClient(CIO) { - install(WebSockets) - install(Logging) { - level = LogLevel.NONE - } - } - - private var session: WebSocketSession? = null - - private val _messages = Channel(Channel.UNLIMITED) - val messages: Flow = _messages.receiveAsFlow() - - private val _connection = Channel(Channel.UNLIMITED) - val connection: Flow = _connection.receiveAsFlow() - - suspend fun send(message: ClientMessage) { - runCatching { - session?.send(json.encodeToString(message)) - } - } - - suspend fun connect() { - var delayMs = INITIAL_RETRY_DELAY_MS - var attempt = 0 - while (true) { - runCatching { - client.webSocket(method = HttpMethod.Get, host = host, port = port, path = "/stream") { - session = this - _connection.send(ConnectionEvent.Connected) - delayMs = INITIAL_RETRY_DELAY_MS - attempt = 0 - for (frame in incoming) { - if (frame is Frame.Text) { - runCatching { - json.decodeFromString(frame.readText()) - }.onSuccess { msg -> - _messages.send(msg) - } - } - } - } - }.onFailure { } - session = null - _connection.send(ConnectionEvent.Disconnected) - attempt++ - val nextRetryAtMs = System.currentTimeMillis() + delayMs - _connection.send(ConnectionEvent.RetryScheduled(attempt = attempt, nextRetryAtMs = nextRetryAtMs)) - delay(delayMs) - delayMs = minOf(delayMs * 2, MAX_RETRY_DELAY_MS) - } - } - - fun close() { - client.close() - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/SingleConsumerContractTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/SingleConsumerContractTest.kt deleted file mode 100644 index 5a34a597..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/SingleConsumerContractTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.correx.apps.tui - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import java.io.File - -class SingleConsumerContractTest { - - private val sourceRoot = File("src/main/kotlin") - - private fun countOccurrences(literal: String): Int = - sourceRoot.walkTopDown() - .filter { it.isFile && it.extension == "kt" } - .sumOf { file -> - file.readText().split(literal).size - 1 - } - - @Test - fun `ws messages has exactly one collector`() { - assertEquals(1, countOccurrences("ws.messages.collect"), - "Expected exactly one ws.messages.collect in src/main/kotlin") - } - - @Test - fun `ws connection has exactly one collector`() { - assertEquals(1, countOccurrences("ws.connection.collect"), - "Expected exactly one ws.connection.collect in src/main/kotlin") - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt deleted file mode 100644 index ed0742c1..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/KeyResolverTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.correx.apps.tui.input - -import com.correx.apps.tui.KeyEvent -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.InputMode -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class KeyResolverTest { - - private fun resolve( - key: KeyEvent, - displayState: DisplayState = DisplayState.IDLE, - inputMode: InputMode = InputMode.ROUTER, - hasPendingApproval: Boolean = false, - inputText: String = "", - ) = KeyResolver.resolve(key, displayState, inputMode, hasPendingApproval, inputText) - - // ── Global actions (work regardless of display state / input mode) ── - - @Test - fun `Quit in ROUTER mode resolves to Quit`() { - assertEquals(Action.Quit, resolve(KeyEvent.Quit)) - } - - @Test - fun `Quit in FILTER mode resolves to Quit`() { - assertEquals(Action.Quit, resolve(KeyEvent.Quit, inputMode = InputMode.FILTER)) - } - - @Test - fun `Approve resolves globally to ApproveActive`() { - assertEquals(Action.ApproveActive, resolve(KeyEvent.Approve)) - } - - @Test - fun `Reject resolves globally to RejectActive`() { - assertEquals(Action.RejectActive, resolve(KeyEvent.Reject)) - } - - // ── FILTER mode ── - - @Test - fun `NavUp in FILTER mode resolves to NavigateUp`() { - assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, inputMode = InputMode.FILTER)) - } - - @Test - fun `NavDown in FILTER mode resolves to NavigateDown`() { - assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, inputMode = InputMode.FILTER)) - } - - @Test - fun `Enter in FILTER mode on blank input resolves to null`() { - assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER)) - } - - @Test - fun `Enter in FILTER mode on non-blank input resolves to SubmitInput`() { - assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = "test")) - } - - @Test - fun `Enter in FILTER mode on whitespace-only input resolves to null`() { - assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = " ")) - } - - @Test - fun `Escape in FILTER mode resolves to CancelInput`() { - assertEquals(Action.CancelInput, resolve(KeyEvent.Escape, inputMode = InputMode.FILTER)) - } - - @Test - fun `Backspace in FILTER mode resolves to Backspace`() { - assertEquals(Action.Backspace, resolve(KeyEvent.Backspace, inputMode = InputMode.FILTER)) - } - - @Test - fun `CharInput in FILTER mode resolves to AppendChar`() { - assertEquals(Action.AppendChar('a'), resolve(KeyEvent.CharInput('a'), inputMode = InputMode.FILTER)) - } - - // ── IDLE display state ── - - @Test - fun `NavUp in IDLE resolves to NavigateUp`() { - assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, DisplayState.IDLE)) - } - - @Test - fun `NavDown in IDLE resolves to NavigateDown`() { - assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, DisplayState.IDLE)) - } - - @Test - fun `Enter in IDLE on blank input resolves to SubmitInput`() { - assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, DisplayState.IDLE)) - } - - @Test - fun `Enter in IDLE on non-blank input resolves to SubmitInput`() { - assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, DisplayState.IDLE, inputText = "wf")) - } - - // ── IN_SESSION display state ── - - @Test - fun `NavUp in IN_SESSION resolves to NavigateUp`() { - assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, DisplayState.IN_SESSION)) - } - - @Test - fun `NavDown in IN_SESSION resolves to NavigateDown`() { - assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, DisplayState.IN_SESSION)) - } - - @Test - fun `CharInput in ROUTER mode resolves to AppendChar`() { - assertEquals(Action.AppendChar('x'), resolve(KeyEvent.CharInput('x'))) - } - - @Test - fun `ShowPendingApproval in IN_SESSION with pending approval emits action`() { - assertEquals( - Action.ShowPendingApproval, - resolve(KeyEvent.ShowPendingApproval, DisplayState.IN_SESSION, hasPendingApproval = true), - ) - } - - @Test - fun `ShowPendingApproval in IN_SESSION without pending approval resolves to null`() { - assertNull(resolve(KeyEvent.ShowPendingApproval, DisplayState.IN_SESSION, hasPendingApproval = false)) - } - - // ── APPROVAL display state ── - - @Test - fun `Enter in APPROVAL resolves to SubmitApprovalDecision`() { - assertEquals(Action.SubmitApprovalDecision, resolve(KeyEvent.Enter, DisplayState.APPROVAL)) - } - - @Test - fun `Escape in APPROVAL resolves to CancelInput`() { - assertEquals(Action.CancelInput, resolve(KeyEvent.Escape, DisplayState.APPROVAL)) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt deleted file mode 100644 index 313c16b8..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/input/TambouiKeyMapperTest.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.correx.apps.tui.input - -import com.correx.apps.tui.KeyEvent -import dev.tamboui.tui.event.KeyCode -import dev.tamboui.tui.event.KeyModifiers -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test -import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent - -class TambouiKeyMapperTest { - - // --- Ctrl-C always → Cancel --- - - @Test - fun `ctrl-c maps to Cancel`() { - assertEquals(KeyEvent.Cancel, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code))) - } - - // --- Ctrl+key → keybind events --- - - @Test - fun `ctrl-q maps to Quit`() { - assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code))) - } - - @Test - fun `ctrl-a maps to Approve`() { - assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code))) - } - - @Test - fun `ctrl-r maps to Reject`() { - assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'r'.code))) - } - - @Test - fun `ctrl-n maps to NewSession`() { - assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code))) - } - - @Test - fun `ctrl-h maps to ShowPendingApproval`() { - assertEquals(KeyEvent.ShowPendingApproval, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code))) - } - - @Test - fun `ctrl-e maps to ToggleEventStrip`() { - assertEquals(KeyEvent.ToggleEventStrip, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code))) - } - - @Test - fun `ctrl+unbound key maps to null`() { - assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'z'.code))) - } - - // --- Alt always → null --- - - @Test - fun `alt modifier maps to null`() { - assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code))) - } - - // --- Bare chars always → CharInput (never trigger keybinds) --- - - @Test - fun `bare a maps to CharInput in ROUTER`() { - assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'))) - } - - @Test - fun `bare q maps to CharInput in ROUTER`() { - assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'))) - } - - @Test - fun `bare s maps to CharInput in ROUTER`() { - assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'))) - } - - @Test - fun `bare x maps to CharInput in ROUTER`() { - assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'))) - } - - @Test - fun `bare q maps to CharInput in FILTER`() { - assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'))) - } - - @Test - fun `bare q maps to CharInput`() { - assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'))) - } - - // --- KeyCode mappings --- - - @Test - fun `UP maps to NavUp`() { - assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP))) - } - - @Test - fun `DOWN maps to NavDown`() { - assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN))) - } - - @Test - fun `ENTER maps to Enter`() { - assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER))) - } - - @Test - fun `ESCAPE maps to Escape`() { - assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE))) - } - - @Test - fun `BACKSPACE maps to Backspace`() { - assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE))) - } - - @Test - fun `TAB maps to Tab`() { - assertEquals(KeyEvent.Tab, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB))) - } - - // --- ISO control chars → null --- - - @Test - fun `ESC codepoint as CHAR event maps to null`() { - assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B))) - } - - @Test - fun `NUL codepoint as CHAR event maps to null`() { - assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00))) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ConnectionReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ConnectionReducerTest.kt deleted file mode 100644 index 578d3241..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/ConnectionReducerTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.ConnectionState -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class ConnectionReducerTest { - - private fun reduce(connection: ConnectionState = ConnectionState(), action: Action) = - ConnectionReducer.reduce(connection, action) - - @Test - fun `Connected sets connected true and clears reconnection state`() { - val initial = ConnectionState(connected = false, reconnecting = true, attempt = 3, nextRetryAtMs = 9999L) - val (state, effects) = reduce(connection = initial, action = Action.Connected) - assertTrue(state.connected) - assertFalse(state.reconnecting) - assertEquals(0, state.attempt) - assertNull(state.nextRetryAtMs) - assertTrue(effects.isEmpty()) - } - - @Test - fun `Disconnected sets connected false and reconnecting true`() { - val initial = ConnectionState(connected = true) - val (state, effects) = reduce(connection = initial, action = Action.Disconnected) - assertFalse(state.connected) - assertTrue(state.reconnecting) - assertTrue(effects.isEmpty()) - } - - @Test - fun `RetryScheduled updates attempt and nextRetryAtMs`() { - val (state, effects) = reduce(action = Action.RetryScheduled(attempt = 2, nextRetryAtMs = 12345L)) - assertTrue(state.reconnecting) - assertEquals(2, state.attempt) - assertEquals(12345L, state.nextRetryAtMs) - assertTrue(effects.isEmpty()) - } - - @Test - fun `Unknown action is no-op`() { - val initial = ConnectionState(connected = true) - val (state, _) = reduce(connection = initial, action = Action.NavigateUp) - assertEquals(initial, state) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/EffectDispatchOrderTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/EffectDispatchOrderTest.kt deleted file mode 100644 index 8d2141ff..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/EffectDispatchOrderTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.correx.apps.tui.reducer - -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -/** - * Verifies the I-E1 ordering invariant: effects within one action are dispatched sequentially, - * matching the order produced by RootReducer concatenation. - * - * The old fire-and-forget loop (`effects.forEach { launch { dispatcher.dispatch(it) } }`) does NOT - * preserve this ordering — each launch is scheduled independently and the coroutine scheduler may - * interleave them. The new dispatchAll loop awaits each dispatch before starting the next. - */ -class EffectDispatchOrderTest { - - @Test - fun `sequential loop preserves order even when first dispatch delays`(): Unit = runBlocking { - val observed = mutableListOf() - - // Simulate what dispatchAll does: for (effect in effects) dispatcher.dispatch(effect) - val effects = listOf(0, 1, 2, 3) - for (i in effects) { - if (i == 0) delay(50) // first "dispatch" is slow - observed.add(i) - } - - assertEquals(listOf(0, 1, 2, 3), observed) - } - - @Test - fun `fire-and-forget loop breaks ordering when first dispatch delays`(): Unit = runBlocking { - val observed = mutableListOf() - - // Reproduce what the OLD code did: launch each effect independently. - // The delay on index 0 causes later indices to be observed first. - val jobs = (0..3).map { i -> - launch { - if (i == 0) delay(50) - observed.add(i) - } - } - jobs.forEach { it.join() } - - // With the old approach, 0 arrives last due to the delay. - assertEquals(listOf(1, 2, 3, 0), observed) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt deleted file mode 100644 index b2387981..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.InputMode -import com.correx.apps.tui.state.TuiState -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class InputReducerTest { - - private fun reduce( - state: TuiState = TuiState(), - action: Action, - ) = InputReducer.reduce(state, action) - - @Test - fun `OpenNewSessionPrompt sets mode to ROUTER with empty buffer`() { - val (state, effects) = reduce(action = Action.OpenNewSessionPrompt) - assertEquals(InputMode.ROUTER, state.inputMode) - assertEquals("", state.inputBuffer) - assertTrue(effects.isEmpty()) - } - - @Test - fun `AppendChar appends character at cursor position`() { - val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab", inputCursor = 2), - action = Action.AppendChar('c'), - ) - assertEquals("abc", state.inputBuffer) - assertTrue(effects.isEmpty()) - } - - @Test - fun `AppendChar inserts at cursor when not at end`() { - val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab", inputCursor = 1), - action = Action.AppendChar('x'), - ) - assertEquals("axb", state.inputBuffer) - assertEquals(2, state.inputCursor) - assertTrue(effects.isEmpty()) - } - - @Test - fun `Backspace drops character before cursor`() { - val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "abc", inputCursor = 3), - action = Action.Backspace, - ) - assertEquals("ab", state.inputBuffer) - assertTrue(effects.isEmpty()) - } - - @Test - fun `Backspace on empty buffer stays empty`() { - val (state, _) = reduce( - state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = ""), - action = Action.Backspace, - ) - assertEquals("", state.inputBuffer) - } - - @Test - fun `CancelInput resets to ROUTER mode with empty buffer`() { - val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.FILTER, inputBuffer = "hello"), - action = Action.CancelInput, - ) - assertEquals(InputMode.ROUTER, state.inputMode) - assertEquals("", state.inputBuffer) - assertTrue(effects.isEmpty()) - } - - @Test - fun `SubmitInput clears input buffer with no effects`() { - val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow"), - action = Action.SubmitInput, - ) - assertEquals("", state.inputBuffer) - assertTrue(effects.isEmpty()) - } - - @Test - fun `SubmitInput with blank text clears input buffer`() { - val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = " "), - action = Action.SubmitInput, - ) - assertEquals("", state.inputBuffer) - assertTrue(effects.isEmpty()) - } - - @Test - fun `SubmitInput in FILTER mode clears buffer with no effects`() { - val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.FILTER, inputBuffer = "myfilter"), - action = Action.SubmitInput, - ) - assertEquals("", state.inputBuffer) - assertTrue(effects.isEmpty()) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerQuitTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerQuitTest.kt deleted file mode 100644 index 5db03e48..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerQuitTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.TuiState -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class RootReducerQuitTest { - - private val fixedClock: () -> Long = { 1000L } - - @Test - fun `Effect Quit is the terminal effect when Action Quit is reduced`() { - val (_, effects) = RootReducer.reduce(TuiState(snapshotPhase = false), Action.Quit, fixedClock) - assertEquals(1, effects.count { it is Effect.Quit }) - assertTrue(effects.last() is Effect.Quit) - } - - @Test - fun `no Effect Quit when action is not Quit`() { - val (_, effects) = RootReducer.reduce(TuiState(snapshotPhase = false), Action.Connected, fixedClock) - assertFalse(effects.any { it is Effect.Quit }) - } - - @Test - fun `sub-reducers are invoked for Action Quit`() { - val initial = TuiState(snapshotPhase = false) - val (state, _) = RootReducer.reduce(initial, Action.Quit, fixedClock) - // ConnectionReducer handles Action.Quit by keeping state; verify state went through sub-reducers - // (state is the result of all sub-reducers, not an early-exit) - assertEquals(initial.inputMode, state.inputMode) - assertEquals(initial.sessions, state.sessions) - assertEquals(initial.connection, state.connection) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt deleted file mode 100644 index 20f285c0..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt +++ /dev/null @@ -1,465 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ClientMessage -import com.correx.apps.server.protocol.ProviderHealthDto -import com.correx.apps.server.protocol.RiskSummaryDto -import com.correx.apps.server.protocol.ServerMessage -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.ConnectionState -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.InputMode -import com.correx.apps.tui.state.ProviderType -import com.correx.apps.tui.state.RouterEntry -import com.correx.apps.tui.state.SessionSummary -import com.correx.apps.tui.state.SessionsState -import com.correx.apps.tui.state.TuiState -import com.correx.apps.tui.state.displayState -import com.correx.core.approvals.Tier -import com.correx.core.events.types.ApprovalRequestId -import com.correx.core.events.types.SessionId -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class RootReducerTest { - - private val fixedClock: () -> Long = { 1000L } - - @Test - fun `TuiState defaults include snapshot phase, pending events, and cursors`() { - val state = TuiState() - assertEquals(true, state.snapshotPhase) - assertEquals(emptyList(), state.pendingEvents) - assertEquals(emptyMap(), state.cursors) - } - - @Test - fun `SubmitInput in ROUTER mode emits StartChatSession effect and resets buffer`() { - val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow") - val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock) - assertEquals(InputMode.ROUTER, state.inputMode) - assertEquals("", state.inputBuffer) - val wsEffects = effects.filterIsInstance() - assertEquals(1, wsEffects.size) - val msg = wsEffects[0].message as ClientMessage.StartChatSession - assertEquals("my-workflow", msg.text) - assertTrue(msg.sessionId.value.isNotBlank()) - } - - @Test - fun `Connected action updates connection state without touching others`() { - val initial = TuiState() - val (state, effects) = RootReducer.reduce(initial, Action.Connected, fixedClock) - assertTrue(state.connection.connected) - assertTrue(effects.isEmpty()) - assertEquals(initial.inputMode, state.inputMode) - assertEquals(initial.inputBuffer, state.inputBuffer) - assertEquals(initial.sessions, state.sessions) - assertEquals(initial.provider, state.provider) - } - - @Test - fun `NavigateDown in IDLE with no selection selects first session`() { - val state = TuiState( - snapshotPhase = false, - sessions = SessionsState( - sessions = listOf(session("s1"), session("s2")), - selectedId = null, - ), - ) - val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock) - assertEquals("s1", result.sessions.selectedId) - } - - @Test - fun `NavigateUp in IDLE selects last session when none selected`() { - val state = TuiState( - snapshotPhase = false, - sessions = SessionsState( - sessions = listOf(session("a"), session("b"), session("c")), - selectedId = null, - ), - ) - val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock) - assertEquals("c", result.sessions.selectedId) - } - - @Test - fun `SnapshotComplete flips snapshotPhase and replays buffered events in order`() { - val startMsg1 = com.correx.apps.server.protocol.ServerMessage.SessionStarted( - sessionId = com.correx.core.events.types.SessionId("s1"), - workflowId = "wf", - sequence = 1L, - sessionSequence = 1L, - ) - val startMsg2 = com.correx.apps.server.protocol.ServerMessage.SessionStarted( - sessionId = com.correx.core.events.types.SessionId("s2"), - workflowId = "wf", - sequence = 2L, - sessionSequence = 1L, - ) - val initial = TuiState( - snapshotPhase = true, - pendingEvents = listOf(startMsg1, startMsg2), - ) - val (result, _) = RootReducer.reduce( - initial, - Action.ServerEventReceived(com.correx.apps.server.protocol.ServerMessage.SnapshotComplete), - fixedClock, - ) - assertEquals(false, result.snapshotPhase) - assertTrue(result.pendingEvents.isEmpty()) - assertEquals(2, result.sessions.sessions.size) - assertEquals(1L, result.cursors["s1"]) - assertEquals(1L, result.cursors["s2"]) - } - - @Test - fun `Disconnected resets snapshot state and sets connection to disconnected`() { - val state = TuiState( - snapshotPhase = false, - cursors = mapOf("s1" to 3L), - connection = ConnectionState(connected = true), - ) - - val (newState, _) = RootReducer.reduce(state, Action.Disconnected, fixedClock) - - assertEquals(true, newState.snapshotPhase) - assertTrue(newState.cursors.isEmpty()) - assertEquals(false, newState.connection.connected) - assertEquals(state.sessions.sessions, newState.sessions.sessions) - } - - @Test - fun `SnapshotComplete with empty pendingEvents flips phase and produces no sessions`() { - val initial = TuiState(snapshotPhase = true, pendingEvents = emptyList()) - val (result, effects) = RootReducer.reduce( - initial, - Action.ServerEventReceived(com.correx.apps.server.protocol.ServerMessage.SnapshotComplete), - fixedClock, - ) - assertEquals(false, result.snapshotPhase) - assertTrue(result.pendingEvents.isEmpty()) - assertTrue(result.sessions.sessions.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, - sessionEntered = true, - 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, - sessionEntered = true, - 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, - sessionEntered = true, - 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, - sessionEntered = true, - sessions = SessionsState( - sessions = listOf(session("s1")), - selectedId = "s1", - ), - inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")), - inputHistoryIndex = 0, - inputBuffer = "msg1", - ) - val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock) - assertEquals(1, result.inputHistoryIndex) - assertEquals("msg2", result.inputBuffer) - } - - @Test - fun `NavigateUp in IN_SESSION with FILTER mode does not navigate history`() { - val state = TuiState( - snapshotPhase = false, - inputMode = InputMode.FILTER, - sessions = SessionsState( - sessions = listOf(session("s1")), - selectedId = "s1", - ), - inputHistory = mapOf("s1" to listOf("msg1", "msg2")), - inputHistoryIndex = -1, - inputBuffer = "dont touch", - ) - val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock) - // In FILTER mode, history nav should not activate - assertEquals(-1, result.inputHistoryIndex) - assertEquals("dont touch", result.inputBuffer) - } - - @Test - fun `AppendChar followed by Backspace returns to original buffer`() { - val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab") - val (s1, _) = RootReducer.reduce(initial, Action.AppendChar('c'), fixedClock) - val (s2, _) = RootReducer.reduce(s1, Action.Backspace, fixedClock) - assertEquals("ab", s2.inputBuffer) - } - - private fun approvalRequiredMsg(sessionId: String, requestId: String = "r1") = - ServerMessage.ApprovalRequired( - sessionId = SessionId(sessionId), - requestId = ApprovalRequestId(requestId), - tier = Tier.T2, - riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"), - toolName = "bash", - preview = null, - sequence = 1L, - sessionSequence = 1L, - ) - - private fun session(id: String) = SessionSummary(id = id, status = "ACTIVE", workflowId = "wf", lastEventAt = 0L) - - @Test - fun `ApprovalRequired on selected session transitions display state to APPROVAL`() { - val initial = TuiState( - snapshotPhase = false, - sessions = SessionsState(sessions = listOf(session("s1")), selectedId = "s1"), - ) - val (state, _) = RootReducer.reduce( - initial, Action.ServerEventReceived(approvalRequiredMsg("s1")), fixedClock, - ) - assertEquals(DisplayState.APPROVAL, state.displayState) - assertEquals("r1", state.sessions.sessions[0].pendingApproval?.requestId) - } - - @Test - fun `ApprovalRequired on non-selected session does not transition to APPROVAL`() { - val initial = TuiState( - snapshotPhase = false, - sessionEntered = true, - sessions = SessionsState( - sessions = listOf(session("s1"), session("s2")), - selectedId = "s1", - ), - ) - val (state, _) = RootReducer.reduce( - initial, Action.ServerEventReceived(approvalRequiredMsg("s2")), fixedClock, - ) - assertEquals(DisplayState.IN_SESSION, state.displayState) - assertEquals(null, state.sessions.sessions.find { it.id == "s1" }?.pendingApproval) - assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId) - } - - @Test - fun `ProviderStatusChanged updates currentModel and providerType`() { - val initial = TuiState(snapshotPhase = false, currentModel = null, providerType = ProviderType.LOCAL) - val msg = ServerMessage.ProviderStatusChanged( - providerId = "llama.cpp", - status = ProviderHealthDto("llama.cpp", "healthy", null), - ) - val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(msg), fixedClock) - assertEquals("llama.cpp", state.currentModel) - assertEquals(ProviderType.LOCAL, state.providerType) - } - - @Test - fun `ProviderStatusChanged for remote provider sets providerType to REMOTE`() { - val initial = TuiState(snapshotPhase = false, currentModel = null, providerType = ProviderType.LOCAL) - val msg = ServerMessage.ProviderStatusChanged( - providerId = "openai", - status = ProviderHealthDto("openai", "healthy", null), - ) - val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(msg), fixedClock) - assertEquals("openai", state.currentModel) - assertEquals(ProviderType.REMOTE, state.providerType) - } - - @Test - fun `SessionStarted switches selectedId to new session when another session is selected`() { - val initial = TuiState( - snapshotPhase = false, - sessions = SessionsState( - sessions = listOf(session("s1"), session("s2")), - selectedId = "s1", - ), - ) - val startedMsg = ServerMessage.SessionStarted( - sessionId = SessionId("s2"), - workflowId = "wf", - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(startedMsg), fixedClock) - assertEquals("s2", state.sessions.selectedId) - assertTrue(state.sessionEntered) - } - - @Test - fun `ToolCompleted with diff appends diff as tool entry to per-session routerMessages`() { - val initial = TuiState(snapshotPhase = false) - val diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old content\n+new content" - val toolCompleted = ServerMessage.ToolCompleted( - sessionId = SessionId("s1"), - toolName = "file_write", - outputSummary = "wrote 3 lines", - occurredAt = fixedClock(), - diff = diff, - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock) - val msgs = state.routerMessages["s1"] - assertEquals(1, msgs?.size) - val entry = msgs?.first() - assertEquals("tool", entry?.role) - assertTrue(entry?.content?.contains("--- a/file.txt") == true) - assertTrue(entry?.content?.contains("+new content") == true) - } - - @Test - fun `ToolCompleted without diff does not append to routerMessages`() { - val existing = listOf(RouterEntry("user", "existing msg")) - val initial = TuiState(snapshotPhase = false, routerMessages = mapOf("s1" to existing)) - val toolCompleted = ServerMessage.ToolCompleted( - sessionId = SessionId("s1"), - toolName = "file_write", - outputSummary = "wrote 3 lines", - occurredAt = fixedClock(), - diff = null, - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock) - val msgs = state.routerMessages["s1"] - assertEquals(1, msgs?.size) - assertEquals(RouterEntry("user", "existing msg"), msgs?.first()) - } - - @Test - fun `routerMessages are isolated per session`() { - val initial = TuiState(snapshotPhase = false) - val msg1 = ServerMessage.RouterResponseMessage( - sessionId = SessionId("s1"), content = "hello from s1", - steeringEmitted = false, sequence = null, sessionSequence = null, - ) - val msg2 = ServerMessage.RouterResponseMessage( - sessionId = SessionId("s2"), content = "hello from s2", - steeringEmitted = false, sequence = null, sessionSequence = null, - ) - val after1 = RootReducer.reduce(initial, Action.ServerEventReceived(msg1), fixedClock).first - val after2 = RootReducer.reduce(after1, Action.ServerEventReceived(msg2), fixedClock).first - assertEquals(listOf(RouterEntry("router", "hello from s1")), after2.routerMessages["s1"]) - assertEquals(listOf(RouterEntry("router", "hello from s2")), after2.routerMessages["s2"]) - } - - @Test - fun `SubmitInput in IDLE with text creates optimistic session and enters session`() { - val initial = TuiState(snapshotPhase = false, inputMode = InputMode.ROUTER, inputBuffer = "my chat text") - val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock) - assertTrue(state.sessionEntered) - assertNotNull(state.sessions.selectedId) - assertEquals(1, state.sessions.sessions.size) - assertEquals("STARTING", state.sessions.sessions[0].status) - assertEquals(state.sessions.selectedId, state.sessions.sessions[0].id) - val wsEffects = effects.filterIsInstance() - assertEquals(1, wsEffects.size) - assertTrue(wsEffects[0].message is ClientMessage.StartChatSession) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt deleted file mode 100644 index 5128f111..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SessionsReducerTest.kt +++ /dev/null @@ -1,429 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.ApprovalDecision -import com.correx.apps.server.protocol.ApprovalDto -import com.correx.apps.server.protocol.ClientMessage -import com.correx.apps.server.protocol.EventEntryDto -import com.correx.apps.server.protocol.PauseReason -import com.correx.apps.server.protocol.RiskSummaryDto -import com.correx.apps.server.protocol.ServerMessage -import com.correx.apps.server.protocol.SessionStateDto -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.DisplayState -import com.correx.apps.tui.state.InputMode -import com.correx.apps.tui.state.ApprovalInfo -import com.correx.apps.tui.state.SessionSummary -import com.correx.apps.tui.state.SessionsState -import com.correx.core.approvals.Tier -import com.correx.core.events.types.ApprovalRequestId -import com.correx.core.events.types.SessionId -import com.correx.core.events.types.StageId -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class SessionsReducerTest { - - private val fixedClock: () -> Long = { 1000L } - - private fun session(id: String) = SessionSummary( - id = id, - status = "ACTIVE", - workflowId = "wf", - lastEventAt = 0L, - currentStage = null, - lastOutput = null, - ) - - private fun reduce( - sessions: SessionsState = SessionsState(), - displayState: DisplayState = DisplayState.IDLE, - inputMode: InputMode = InputMode.ROUTER, - inputText: String = "", - action: Action, - ) = SessionsReducer.reduce( - SessionsReducerContext( - sessions = sessions, - displayState = displayState, - inputMode = inputMode, - inputText = inputText, - action = action, - clock = fixedClock, - ), - ) - - @Test - fun `NavigateUp wraps from top to bottom`() { - val s = SessionsState(sessions = listOf(session("a"), session("b"), session("c")), selectedId = "a") - val (state, effects) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateUp) - assertEquals("c", state.selectedId) - assertTrue(effects.isEmpty()) - } - - @Test - fun `NavigateDown advances selection`() { - val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "a") - val (state, _) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateDown) - assertEquals("b", state.selectedId) - } - - @Test - fun `NavigateDown wraps from bottom to top`() { - val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "b") - val (state, _) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateDown) - assertEquals("a", state.selectedId) - } - - @Test - fun `SubmitInput in Filter mode copies inputText into filter`() { - val s = SessionsState() - val (state, effects) = reduce( - sessions = s, inputMode = InputMode.FILTER, inputText = "myfilter", action = Action.SubmitInput, - ) - assertEquals("myfilter", state.filter) - assertTrue(effects.isEmpty()) - } - - @Test - fun `SubmitInput in Filter mode with empty text clears filter`() { - val s = SessionsState(filter = "old") - val (state, effects) = reduce( - sessions = s, inputMode = InputMode.FILTER, inputText = "", action = Action.SubmitInput, - ) - assertEquals("", state.filter) - assertTrue(effects.isEmpty()) - } - - @Test - fun `CancelInput in Filter mode clears filter`() { - val s = SessionsState(filter = "something") - val (state, effects) = reduce(sessions = s, inputMode = InputMode.FILTER, action = Action.CancelInput) - assertEquals("", state.filter) - assertTrue(effects.isEmpty()) - } - - @Test - fun `CancelInput in non-Filter mode is no-op`() { - val s = SessionsState(filter = "something") - val (state, _) = reduce(sessions = s, inputMode = InputMode.ROUTER, action = Action.CancelInput) - assertEquals("something", state.filter) - } - - @Test - fun `ServerEventReceived SessionStarted appends session and sets selection`() { - val msg = ServerMessage.SessionStarted(sessionId = SessionId("s1"), workflowId = "wf1", sequence = 1L, sessionSequence = 1L) - val (state, effects) = reduce(action = Action.ServerEventReceived(msg)) - assertEquals(1, state.sessions.size) - assertEquals("s1", state.sessions[0].id) - assertEquals("s1", state.selectedId) - assertTrue(effects.isEmpty()) - } - - @Test - fun `ServerEventReceived SessionStarted preserves existing selection`() { - val existing = SessionsState(sessions = listOf(session("existing")), selectedId = "existing") - val msg = ServerMessage.SessionStarted(sessionId = SessionId("new"), workflowId = "wf", sequence = 1L, sessionSequence = 1L) - val (state, _) = reduce(sessions = existing, action = Action.ServerEventReceived(msg)) - assertEquals("existing", state.selectedId) - assertEquals(2, state.sessions.size) - } - - @Test - fun `ServerEventReceived SessionCompleted updates status to COMPLETED`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.SessionCompleted(sessionId = SessionId("s1"), sequence = 1L, sessionSequence = 1L) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals("COMPLETED", state.sessions[0].status) - assertEquals(1000L, state.sessions[0].lastEventAt) - } - - @Test - fun `ServerEventReceived SessionFailed updates status to FAILED`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.SessionFailed(sessionId = SessionId("s1"), reason = "oops", sequence = 1L, sessionSequence = 1L) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals("FAILED", state.sessions[0].status) - } - - @Test - fun `ServerEventReceived StageCompleted clears currentStage`() { - val s = SessionsState( - sessions = listOf(session("s1").copy(currentStage = "stage-1")), - selectedId = "s1", - ) - val msg = ServerMessage.StageCompleted( - sessionId = SessionId("s1"), - stageId = StageId("stage-1"), - occurredAt = fixedClock(), - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals(null, state.sessions[0].currentStage) - assertEquals(1000L, state.sessions[0].lastEventAt) - } - - @Test - fun `ServerEventReceived StageFailed clears currentStage`() { - val s = SessionsState( - sessions = listOf(session("s1").copy(currentStage = "stage-1")), - selectedId = "s1", - ) - val msg = ServerMessage.StageFailed( - sessionId = SessionId("s1"), - stageId = StageId("stage-1"), - reason = "error", - occurredAt = fixedClock(), - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals(null, state.sessions[0].currentStage) - assertEquals(1000L, state.sessions[0].lastEventAt) - } - - @Test - fun `CancelSelectedSession emits CancelSession effect`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession) - assertEquals(1, effects.size) - val effect = effects[0] as Effect.SendWs - val msg = effect.message as ClientMessage.CancelSession - assertEquals("s1", msg.sessionId.value) - } - - @Test - fun `CancelSelectedSession with no selection emits no effects`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = null) - val (_, effects) = reduce(sessions = s, action = Action.CancelSelectedSession) - assertTrue(effects.isEmpty()) - } - - @Test - fun `ServerEventReceived InferenceCompleted updates lastOutput and lastResponseText`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.InferenceCompleted( - sessionId = SessionId("s1"), stageId = StageId("stage-1"), - outputSummary = "summary", responseText = "full response", - occurredAt = fixedClock(), - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals("summary", state.sessions[0].lastOutput) - assertEquals("full response", state.sessions[0].lastResponseText) - assertEquals(1000L, state.sessions[0].lastEventAt) - } - - @Test - fun `ServerEventReceived InferenceCompleted with empty responseText stores null`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.InferenceCompleted( - sessionId = SessionId("s1"), stageId = StageId("stage-1"), - outputSummary = "", responseText = "", - occurredAt = fixedClock(), - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals(null, state.sessions[0].lastResponseText) - } - - @Test - fun `ServerEventReceived StageStarted sets currentStage`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.StageStarted( - sessionId = SessionId("s1"), - stageId = StageId("stage-1"), - occurredAt = fixedClock(), - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals("stage-1", state.sessions[0].currentStage) - assertEquals(1000L, state.sessions[0].lastEventAt) - } - - @Test - fun `ServerEventReceived ToolCompleted updates lastOutput`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.ToolCompleted( - sessionId = SessionId("s1"), - toolName = "file_write", - outputSummary = "wrote 3 lines", - occurredAt = fixedClock(), - sequence = 1L, - sessionSequence = 1L, - ) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals("file_write: wrote 3 lines", state.sessions[0].lastOutput) - assertEquals(1000L, state.sessions[0].lastEventAt) - } - - @Test - fun `ServerEventReceived SessionPaused with APPROVAL_PENDING sets status label`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.SessionPaused(sessionId = SessionId("s1"), reason = PauseReason.APPROVAL_PENDING, sequence = 1L, sessionSequence = 1L) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals("PAUSED awaiting approval", state.sessions[0].status) - } - - @Test - fun `ApprovalRequired sets pendingApproval on matching session`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.ApprovalRequired( - sessionId = SessionId("s1"), - requestId = ApprovalRequestId("r1"), - tier = Tier.T2, - riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"), - toolName = "bash", - preview = "rm -rf /", - sequence = 1L, - sessionSequence = 1L, - ) - val (state, effects) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals("r1", state.sessions[0].pendingApproval?.requestId) - assertEquals("s1", state.sessions[0].pendingApproval?.sessionId) - assertEquals("bash", state.sessions[0].pendingApproval?.toolName) - assertTrue(effects.isEmpty()) - } - - @Test - fun `ApprovalRequired for unknown session is no-op`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val msg = ServerMessage.ApprovalRequired( - sessionId = SessionId("s2"), - requestId = ApprovalRequestId("r1"), - tier = Tier.T2, - riskSummary = RiskSummaryDto(level = "HIGH", factors = emptyList(), recommendedAction = "review"), - toolName = "bash", - preview = null, - sequence = 1L, - sessionSequence = 1L, - ) - val (state, effects) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals(s.sessions, state.sessions) - assertTrue(effects.isEmpty()) - } - - @Test - fun `SessionSnapshot with pendingApprovals populates pendingApproval on summary`() { - val msg = ServerMessage.SessionSnapshot( - sessionId = SessionId("s1"), - workflowId = "wf-1", - state = SessionStateDto("PAUSED", null, null), - pendingApprovals = listOf(ApprovalDto(requestId = "r9", tier = "T2")), - lastSequence = 1L, - lastSessionSequence = 1L, - ) - val (state, _) = reduce(action = Action.ServerEventReceived(msg)) - assertEquals("r9", state.sessions[0].pendingApproval?.requestId) - assertEquals("PAUSED awaiting approval", state.sessions[0].status) - } - - private fun pendingApproval(requestId: String = "req-1", sessionId: String = "s1") = ApprovalInfo( - requestId = requestId, - sessionId = sessionId, - tier = "T2", - riskSummary = "HIGH", - toolName = "bash", - preview = null, - ) - - @Test - fun `ApproveActive is no-op in SessionsReducer (handled by RootReducer pendingDecision)`() { - val s = SessionsState( - sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())), - selectedId = "s1", - ) - val (state, effects) = reduce(sessions = s, action = Action.ApproveActive) - assertEquals(s, state) - assertTrue(effects.isEmpty()) - } - - @Test - fun `RejectActive is no-op in SessionsReducer (handled by RootReducer pendingDecision)`() { - val s = SessionsState( - sessions = listOf(session("s1").copy(pendingApproval = pendingApproval())), - selectedId = "s1", - ) - val (state, effects) = reduce(sessions = s, action = Action.RejectActive) - assertEquals(s, state) - assertTrue(effects.isEmpty()) - } - - @Test - fun `ApproveActive is no-op when selected session has no pendingApproval`() { - val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") - val (state, effects) = reduce(sessions = s, action = Action.ApproveActive) - assertEquals(s, state) - assertTrue(effects.isEmpty()) - } - - @Test - fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() { - val msg = ServerMessage.SessionSnapshot( - sessionId = SessionId("s1"), - workflowId = "wf-1", - state = SessionStateDto("ACTIVE", null, null), - pendingApprovals = emptyList(), - lastSequence = 1L, - lastSessionSequence = 1L, - ) - val (state, _) = reduce(action = Action.ServerEventReceived(msg)) - assertEquals(null, state.sessions[0].pendingApproval) - } - - @Test - fun `SessionSnapshot recentEvents are mapped to TuiEventEntry on summary`() { - val msg = ServerMessage.SessionSnapshot( - sessionId = SessionId("s1"), - workflowId = "wf-1", - state = SessionStateDto("COMPLETED", null, null), - pendingApprovals = emptyList(), - recentEvents = listOf( - EventEntryDto(timestamp = 1000L, type = "StageStarted", detail = "stage-1"), - EventEntryDto(timestamp = 2000L, type = "InferenceCompleted", detail = "stage-1"), - EventEntryDto(timestamp = 3000L, type = "SessionCompleted", detail = ""), - ), - lastSequence = 1L, - lastSessionSequence = 1L, - ) - val (state, _) = reduce(action = Action.ServerEventReceived(msg)) - val events = state.sessions[0].recentEvents - assertEquals(3, events.size) - assertEquals("StageStarted", events[0].type) - assertEquals("stage-1", events[0].detail) - assertEquals("InferenceCompleted", events[1].type) - assertEquals("SessionCompleted", events[2].type) - assertEquals("", events[2].detail) - } - - @Test - fun `SubmitInput in IDLE creates optimistic STARTING session`() { - val (state, effects) = reduce(action = Action.SubmitInput, inputText = "hello world") - assertEquals(1, state.sessions.size) - assertEquals("STARTING", state.sessions[0].status) - assertEquals(state.sessions[0].id, state.selectedId) - val wsEffects = effects.filterIsInstance() - assertEquals(1, wsEffects.size) - assertTrue(wsEffects[0].message is ClientMessage.StartChatSession) - val chatSession = wsEffects[0].message as ClientMessage.StartChatSession - assertEquals("hello world", chatSession.text) - } - - @Test - fun `SessionStarted reconciles by removing optimistic STARTING session`() { - val optimisticId = SessionId("opt-1") - val optimistic = SessionSummary(id = "opt-1", status = "STARTING", workflowId = "chat", name = "chat", lastEventAt = 0L) - val s = SessionsState(sessions = listOf(optimistic), selectedId = "opt-1") - val msg = ServerMessage.SessionStarted( - sessionId = SessionId("real-1"), workflowId = "wf", sequence = 1L, sessionSequence = 1L, - ) - val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) - assertEquals(1, state.sessions.size) - assertEquals("real-1", state.sessions[0].id) - assertEquals("ACTIVE", state.sessions[0].status) - assertEquals("real-1", state.selectedId) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducerTest.kt deleted file mode 100644 index ca588c0d..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/SnapshotPhaseReducerTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.correx.apps.tui.reducer - -import com.correx.apps.server.protocol.SessionStateDto -import com.correx.apps.server.protocol.ServerMessage -import com.correx.apps.tui.input.Action -import com.correx.apps.tui.state.TuiState -import com.correx.core.events.types.SessionId -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class SnapshotPhaseReducerTest { - - @Test - fun `during snapshotPhase, three live ServerEventReceived actions are buffered and sessions unchanged`() { - var state = TuiState(snapshotPhase = true) - val allDispatched = mutableListOf() - - repeat(3) { - val msg = ServerMessage.SessionStarted( - sessionId = SessionId("s1"), - workflowId = "wf", - sequence = 1L, - sessionSequence = 1L, - ) - val result = SnapshotPhaseReducer.process(state, Action.ServerEventReceived(msg)) - state = result.state - allDispatched += result.actionsToDispatch - } - - assertEquals(3, state.pendingEvents.size) - assertTrue(allDispatched.isEmpty()) - assertTrue(state.sessions.sessions.isEmpty()) - } - - @Test - fun `live event with sessionSequence at or below cursor is dropped`() { - val state = TuiState(snapshotPhase = false, cursors = mapOf("s1" to 5L)) - val action = Action.ServerEventReceived( - ServerMessage.SessionStarted( - sessionId = SessionId("s1"), - workflowId = "wf", - sequence = 5L, - sessionSequence = 5L, - ) - ) - - val result = SnapshotPhaseReducer.process(state, action) - - assertTrue(result.actionsToDispatch.isEmpty()) - assertEquals(5L, result.state.cursors["s1"]) - } - - @Test - fun `live event with sessionSequence one above cursor is applied and cursor advances`() { - val state = TuiState(snapshotPhase = false, cursors = mapOf("s1" to 5L)) - val action = Action.ServerEventReceived( - ServerMessage.SessionStarted( - sessionId = SessionId("s1"), - workflowId = "wf", - sequence = 6L, - sessionSequence = 6L, - ) - ) - - val result = SnapshotPhaseReducer.process(state, action) - - assertEquals(listOf(action), result.actionsToDispatch) - assertEquals(6L, result.state.cursors["s1"]) - } - - @Test - fun `gap event is applied and cursor advances to incoming sequence`() { - val state = TuiState(snapshotPhase = false, cursors = mapOf("s1" to 5L)) - val action = Action.ServerEventReceived( - ServerMessage.SessionStarted( - sessionId = SessionId("s1"), - workflowId = "wf", - sequence = 8L, - sessionSequence = 8L, - ) - ) - - val result = SnapshotPhaseReducer.process(state, action) - - assertEquals(listOf(action), result.actionsToDispatch) - assertEquals(8L, result.state.cursors["s1"]) - } - - @Test - fun `Disconnected resets snapshotPhase, pendingEvents, and cursors`() { - val state = TuiState( - snapshotPhase = false, - cursors = mapOf("s1" to 5L), - pendingEvents = listOf(ServerMessage.ProtocolError("x")), - ) - - val result = SnapshotPhaseReducer.process(state, Action.Disconnected) - - assertEquals(true, result.state.snapshotPhase) - assertTrue(result.state.pendingEvents.isEmpty()) - assertTrue(result.state.cursors.isEmpty()) - assertEquals(listOf(Action.Disconnected), result.actionsToDispatch) - } - - @Test - fun `during snapshotPhase SessionSnapshot installs cursor and is forwarded to sub-reducers`() { - val state = TuiState(snapshotPhase = true) - val snapshot = ServerMessage.SessionSnapshot( - sessionId = SessionId("s1"), - workflowId = "wf-1", - state = SessionStateDto(status = "ACTIVE", currentStageId = null, pauseReason = null), - pendingApprovals = emptyList(), - lastSequence = 1L, - lastSessionSequence = 42L, - ) - val action = Action.ServerEventReceived(snapshot) - - val result = SnapshotPhaseReducer.process(state, action) - - assertEquals(42L, result.state.cursors["s1"]) - assertEquals(listOf(action), result.actionsToDispatch) - assertTrue(result.state.pendingEvents.isEmpty()) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/ApprovalResponseGlobalSocketRoundTripTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/ApprovalResponseGlobalSocketRoundTripTest.kt deleted file mode 100644 index 5a0f2f91..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/ApprovalResponseGlobalSocketRoundTripTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.correx.apps.tui.ws - -import com.correx.apps.server.protocol.ApprovalDecision -import com.correx.apps.server.protocol.ClientMessage -import com.correx.apps.server.protocol.ProtocolSerializer -import com.correx.core.events.types.ApprovalRequestId -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertInstanceOf -import org.junit.jupiter.api.Test - -/** - * Per tui-02 invariant 5: all ClientMessage types (including ApprovalResponse) are sent over - * the single global socket. This test asserts that the JSON encoding used by TuiWsClient.send() - * round-trips through the server-side global socket decoder (ProtocolSerializer.decodeClientMessage) - * as an ApprovalResponse. - */ -class ApprovalResponseGlobalSocketRoundTripTest { - - // Mirrors the Json config in TuiWsClient - private val clientJson = Json { - classDiscriminator = "type" - ignoreUnknownKeys = true - } - - @Test - fun `ApprovalResponse encoded by TUI client decodes on the global socket`() { - val original: ClientMessage = ClientMessage.ApprovalResponse( - requestId = ApprovalRequestId("req-123"), - decision = ApprovalDecision.APPROVE, - steeringNote = null, - ) - - val wire = clientJson.encodeToString(original) - val decoded = ProtocolSerializer.decodeClientMessage(wire) - - assertInstanceOf(ClientMessage.ApprovalResponse::class.java, decoded) - val approval = decoded as ClientMessage.ApprovalResponse - assertEquals("req-123", approval.requestId.value) - assertEquals(ApprovalDecision.APPROVE, approval.decision) - assertEquals(null, approval.steeringNote) - } - - @Test - fun `ApprovalResponse with steering note round-trips`() { - val original: ClientMessage = ClientMessage.ApprovalResponse( - requestId = ApprovalRequestId("req-steer"), - decision = ApprovalDecision.APPROVE, - steeringNote = "please retry with smaller batch", - ) - - val wire = clientJson.encodeToString(original) - val decoded = ProtocolSerializer.decodeClientMessage(wire) as ClientMessage.ApprovalResponse - - assertEquals(ApprovalDecision.APPROVE, decoded.decision) - assertEquals("please retry with smaller batch", decoded.steeringNote) - } -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/TuiWsClientChannelTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/TuiWsClientChannelTest.kt deleted file mode 100644 index e748de8c..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/TuiWsClientChannelTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.correx.apps.tui.ws - -import com.correx.apps.server.protocol.ServerMessage -import com.correx.core.events.types.SessionId -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class TuiWsClientChannelTest { - - @Test - fun `Channel UNLIMITED does not drop messages under high load`(): Unit = runTest { - // Create a test channel to simulate TuiWsClient._messages behavior - val channel = Channel(Channel.UNLIMITED) - - // Collect messages from the channel - val collected = mutableListOf() - val collectJob = launch { - for (msg in channel) { - collected.add(msg) - } - } - - // Send 10k messages rapidly without delay - val sentCount = 10_000 - repeat(sentCount) { i -> - val msg = ServerMessage.SessionStarted( - sessionId = SessionId("session-$i"), - workflowId = "wf-$i", - sequence = i.toLong(), - sessionSequence = i.toLong(), - ) - channel.send(msg) - } - - // Close channel to signal completion - channel.close() - - // Wait for collection to complete - collectJob.join() - - // Verify all messages were received (no silent drops) - assertEquals(sentCount, collected.size, "All $sentCount messages should be received without drops") - } - -} diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/TuiWsClientConnectionChannelTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/TuiWsClientConnectionChannelTest.kt deleted file mode 100644 index 0449192a..00000000 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/ws/TuiWsClientConnectionChannelTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.correx.apps.tui.ws - -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class TuiWsClientConnectionChannelTest { - - @Test - fun `Channel UNLIMITED does not drop connection events under high load`(): Unit = runTest { - // Create a test channel to simulate TuiWsClient._connection behavior - val channel = Channel(Channel.UNLIMITED) - - // Collect events from the channel - val collected = mutableListOf() - val collectJob = launch { - for (event in channel) { - collected.add(event) - } - } - - // Send 10k events rapidly without delay (mix of all event types) - val sentCount = 10_000 - repeat(sentCount) { i -> - val event = when (i % 3) { - 0 -> ConnectionEvent.Connected - 1 -> ConnectionEvent.Disconnected - else -> ConnectionEvent.RetryScheduled(attempt = i, nextRetryAtMs = System.currentTimeMillis() + 1000) - } - channel.send(event) - } - - // Close channel to signal completion - channel.close() - - // Wait for collection to complete - collectJob.join() - - // Verify all events were received (no silent drops) - assertEquals(sentCount, collected.size, "All $sentCount connection events should be received without drops") - } - -} diff --git a/settings.gradle b/settings.gradle index 4b3daed3..26974480 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,7 +9,6 @@ rootProject.name = 'correx' include ':apps:cli' include ':apps:server' -include ':apps:tui' include ':apps:worker' include ':apps:desktop'