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.
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+23
-6
@@ -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<ServerMessage.SessionStarted>(jsonStr)
|
||||
val decoded = json.decodeFromString<ServerMessage.SessionAnnounced>(jsonStr)
|
||||
assertEquals(original.sessionId, decoded.sessionId)
|
||||
assertEquals(original.workflowId, decoded.workflowId)
|
||||
assertEquals(200L, decoded.sequence)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<String>) {
|
||||
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<Effect>) {
|
||||
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)
|
||||
}
|
||||
@@ -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<Line> {
|
||||
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: <type in input bar>", 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()
|
||||
}
|
||||
@@ -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<Line> {
|
||||
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()
|
||||
}
|
||||
@@ -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<Line> {
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
@@ -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<Span> {
|
||||
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<Span>.chip(key: String, label: String) {
|
||||
add(Span.raw(" "))
|
||||
add(Span.styled(key, Theme.accentStyle))
|
||||
add(Span.styled(" $label", Theme.dimStyle))
|
||||
add(Span.raw(" "))
|
||||
}
|
||||
@@ -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<Line?, Line?> {
|
||||
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<Line?, Line?> {
|
||||
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<Line?, Line?> {
|
||||
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<Span>()
|
||||
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<Line?, Line?> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -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<Line> {
|
||||
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()
|
||||
}
|
||||
@@ -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<Line> {
|
||||
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<SessionSummary> {
|
||||
val f = sessions.filter
|
||||
return if (f.isEmpty()) sessions.sessions
|
||||
else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) }
|
||||
}
|
||||
@@ -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<Span>()
|
||||
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<Span> {
|
||||
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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Line> {
|
||||
// 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')}"
|
||||
}
|
||||
@@ -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<Line> {
|
||||
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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<ConnectionState, List<Effect>> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TuiState, List<Effect>> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderState, List<Effect>> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<TuiState, List<Effect>> {
|
||||
val phase = SnapshotPhaseReducer.process(state, action)
|
||||
var current = phase.state
|
||||
val allEffects = mutableListOf<Effect>()
|
||||
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<TuiState, List<Effect>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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<SessionsState, List<Effect>> = 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<SessionSummary> =
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
val toolsByStage = msg.stages.associate { stageDecl ->
|
||||
stageDecl.stageId to stageDecl.tools.map { toolDecl ->
|
||||
ToolManifestEntry(name = toolDecl.name, tier = toolDecl.tier)
|
||||
}
|
||||
}
|
||||
return sessions.copy(
|
||||
sessions = sessions.sessions.map { s ->
|
||||
if (s.id == msg.sessionId.value) {
|
||||
s.copy(toolsByStage = toolsByStage)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
},
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processSessionSnapshotMessage(
|
||||
clock: () -> Long,
|
||||
sessionState: SessionsState,
|
||||
msg: ServerMessage.SessionSnapshot,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
val result = touchSession(sessions, msg.sessionId.value, "COMPLETED", clock)
|
||||
return result to emptyList()
|
||||
}
|
||||
|
||||
private fun processSessionPausedMessage(
|
||||
msg: ServerMessage.SessionPaused,
|
||||
sessions: SessionsState,
|
||||
clock: () -> Long,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> = 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<SessionsState, List<Effect>> {
|
||||
// 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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> {
|
||||
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<SessionsState, List<Effect>> =
|
||||
sessions.copy(workflows = msg.workflows) to emptyList()
|
||||
}
|
||||
@@ -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<Action>)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
data class InputState(
|
||||
val mode: InputMode = InputMode.ROUTER,
|
||||
val text: String = "",
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
data class ProviderState(
|
||||
val id: String = "",
|
||||
val status: String = "unknown",
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
import com.correx.apps.server.protocol.WorkflowDto
|
||||
|
||||
data class SessionsState(
|
||||
val sessions: List<SessionSummary> = emptyList(),
|
||||
val selectedId: String? = null,
|
||||
val filter: String = "",
|
||||
val backgroundUpdateCount: Int = 0,
|
||||
val workflows: List<WorkflowDto> = 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,
|
||||
)
|
||||
@@ -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<String, List<String>> = 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<String, List<RouterEntry>> = 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<ServerMessage> = 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<String, Long> = 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<TuiToolRecord> = emptyList(),
|
||||
val toolsByStage: Map<String, List<ToolManifestEntry>> = emptyMap(),
|
||||
val recentEvents: List<TuiEventEntry> = 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ServerMessage>(Channel.UNLIMITED)
|
||||
val messages: Flow<ServerMessage> = _messages.receiveAsFlow()
|
||||
|
||||
private val _connection = Channel<ConnectionEvent>(Channel.UNLIMITED)
|
||||
val connection: Flow<ConnectionEvent> = _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<ServerMessage>(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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Int>()
|
||||
|
||||
// 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<Int>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Any>(), state.pendingEvents)
|
||||
assertEquals(emptyMap<String, Long>(), 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<Effect.SendWs>()
|
||||
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<Effect.SendWs>()
|
||||
assertEquals(1, wsEffects.size)
|
||||
assertTrue(wsEffects[0].message is ClientMessage.StartChatSession)
|
||||
}
|
||||
}
|
||||
@@ -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<Effect.SendWs>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Action>()
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
-59
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ServerMessage>(Channel.UNLIMITED)
|
||||
|
||||
// Collect messages from the channel
|
||||
val collected = mutableListOf<ServerMessage>()
|
||||
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")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<ConnectionEvent>(Channel.UNLIMITED)
|
||||
|
||||
// Collect events from the channel
|
||||
val collected = mutableListOf<ConnectionEvent>()
|
||||
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")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,7 +9,6 @@ rootProject.name = 'correx'
|
||||
|
||||
include ':apps:cli'
|
||||
include ':apps:server'
|
||||
include ':apps:tui'
|
||||
include ':apps:worker'
|
||||
include ':apps:desktop'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user