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

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

- **DisplayState model**: New `DisplayState` enum governs per-mode key resolution and UI
  rendering. Removes `ROUTER`/`NAVIGATE`/`STEER`/`FILTER` InputMode complexity.
- **Input history navigation**: Up/Down arrows in IN_SESSION cycle through per-session
  input history (ARCH-HISTORY-2/3). `savedInputBuffer` preserves in-flight text.
- **Input history filter**: `Tab` toggles between ROUTER and FILTER modes; history nav
  disabled while filtering.
- **Ctrl-C → Cancel**: Rebind Ctrl-C from Quit to Cancel across all states.
- **Approval flow**: `ApproveActive`/`RejectActive` moved from SessionsReducer to
  RootReducer via `pendingDecision`/`SubmitApprovalDecision`. Approval overlay
  replaced by `ApprovalSurface` component.
- **Event history strip**: Replaces old overlay with a persistent `EventHistoryStrip`
  component, toggled via Ctrl-E.
- **Background update badge**: New `backgroundUpdateCount` on SessionsState tracks
  events arriving for non-selected sessions (ARCH-BADGE-1).
- **StageToolManifest**: New `ServerMessage.StageToolManifest` and `ToolManifestEntry`
  model for pre-declared tool shapes per stage (RF-3).
- **Cleanup**: Removed `ActiveSession`, `ApprovalPanel`, `ToolsPanel` components.
  Removed `InputMode.NAVIGATE`, `InputMode.STEER`, approval overlay state fields.
  Various reducer simplifications (no more overlaid effects for approval responses).
This commit is contained in:
2026-05-26 01:46:14 +04:00
parent 2e3b8e599c
commit f6ef028883
32 changed files with 1180 additions and 701 deletions
@@ -131,6 +131,7 @@ fun main() {
routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
approvalConfig = correxConfig.approval,
)
module.start()
@@ -13,6 +13,7 @@ import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.router.RouterFacade
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.tools.registry.ToolRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -32,6 +33,7 @@ class ServerModule(
val routerFacade: RouterFacade,
val orchestrationRepository: OrchestrationRepository,
val approvalRepository: DefaultApprovalRepository,
val toolRegistry: ToolRegistry,
approvalConfig: ApprovalConfig = ApprovalConfig(),
// Long-lived scope owned by the module — backs the ApprovalCoordinator timers
// and event-store subscription. SupervisorJob so one failure doesn't kill the
@@ -3,18 +3,24 @@ package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ApprovalDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.SessionStateDto
import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.tools.registry.ToolRegistry
class SessionEventBridge(
private val eventStore: EventStore,
private val artifactStore: ArtifactStore,
private val orchestrationRepository: OrchestrationRepository,
private val approvalRepository: DefaultApprovalRepository,
private val workflowRegistry: WorkflowRegistry,
private val toolRegistry: ToolRegistry,
private val send: suspend (ServerMessage) -> Unit,
) {
suspend fun replaySnapshot() {
@@ -46,6 +52,24 @@ class SessionEventBridge(
lastSequence = lastGlobal,
lastSessionSequence = lastSession,
))
// Emit StageToolManifest for this session (ARCH-TOOL-1)
val graph = workflowRegistry.find(orchState.workflowId)
if (graph != null) {
val stages = graph.stages.map { (stageId, stageConfig) ->
val tools = stageConfig.allowedTools.mapNotNull { toolName ->
toolRegistry.resolve(toolName)?.let { tool ->
ToolDecl(name = tool.name, tier = tool.tier.level)
}
}
StageToolDecl(stageId = stageId.value, tools = tools)
}
send(ServerMessage.StageToolManifest(
sessionId = sessionId,
workflowId = orchState.workflowId,
stages = stages,
))
}
}
send(ServerMessage.SnapshotComplete)
}
@@ -40,3 +40,15 @@ enum class PauseReason {
APPROVAL_PENDING,
USER_REQUESTED,
}
@Serializable
data class StageToolDecl(
val stageId: String,
val tools: List<ToolDecl>,
)
@Serializable
data class ToolDecl(
val name: String,
val tier: Int,
)
@@ -211,6 +211,16 @@ sealed interface ServerMessage {
// -- System / infra --
@Serializable
@SerialName("stage.tool_manifest")
data class StageToolManifest(
val sessionId: SessionId,
val workflowId: String,
val stages: List<StageToolDecl>,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
@Serializable
@SerialName("provider.status_changed")
data class ProviderStatusChanged(
@@ -7,6 +7,8 @@ import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ProtocolSerializer
import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
@@ -47,9 +49,11 @@ class GlobalStreamHandler(private val module: ServerModule) {
val bridge = SessionEventBridge(
eventStore = module.eventStore,
artifactStore = module.artifactStore,
send = sendFrame,
orchestrationRepository = module.orchestrationRepository,
approvalRepository = module.approvalRepository,
workflowRegistry = module.workflowRegistry,
toolRegistry = module.toolRegistry,
send = sendFrame,
)
val mapper = DomainEventMapper(module.artifactStore)
@@ -201,6 +205,23 @@ class GlobalStreamHandler(private val module: ServerModule) {
sessionSequence = 0L,
)
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
// Emit StageToolManifest for the newly created session (ARCH-TOOL-1)
val stages = graph.stages.map { (stageId, stageConfig) ->
val tools = stageConfig.allowedTools.mapNotNull { toolName ->
module.toolRegistry.resolve(toolName)?.let { tool ->
ToolDecl(name = tool.name, tier = tool.tier.level)
}
}
StageToolDecl(stageId = stageId.value, tools = tools)
}
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(
ServerMessage.StageToolManifest(
sessionId = sessionId,
workflowId = msg.workflowId,
stages = stages,
),
)))
}
}
@@ -1,6 +1,7 @@
package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
@@ -21,6 +22,8 @@ import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.sessions.projections.replay.EventReplayer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
@@ -98,6 +101,16 @@ class SessionEventBridgeTest {
),
)
private val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<com.correx.apps.server.registry.WorkflowSummary> = emptyList()
override fun find(workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? = null
}
private val noopToolRegistry = object : ToolRegistry {
override fun resolve(name: String): Tool? = null
override fun all(): List<Tool> = emptyList()
}
@Test
fun `replaySnapshot sends mapped messages for all events`() = runTest {
val events = listOf(
@@ -112,6 +125,8 @@ class SessionEventBridgeTest {
noopArtifactStore,
orchRepo,
approvalRepository,
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot()
@@ -138,6 +153,8 @@ class SessionEventBridgeTest {
noopArtifactStore,
orchRepo,
approvalRepository,
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.replaySnapshot()
@@ -162,6 +179,8 @@ class SessionEventBridgeTest {
noopArtifactStore,
activeOrchestrationRepository(),
approvalRepository,
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
bridge.streamLive(sessionId)
@@ -181,7 +200,7 @@ class SessionEventBridgeTest {
fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest {
val store = fakeEventStore(allEventsList = emptyList())
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
bridge.replaySnapshot()
assertEquals(1, sent.size)
assertEquals(ServerMessage.SnapshotComplete, sent[0])
@@ -192,7 +211,7 @@ class SessionEventBridgeTest {
val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
bridge.replaySnapshot()
assertEquals(ServerMessage.SnapshotComplete, sent.last())
}
@@ -207,7 +226,7 @@ class SessionEventBridgeTest {
override suspend fun lastGlobalSequence(): Long = 5L
}
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(5L, snapshot.lastSequence)
@@ -221,7 +240,7 @@ class SessionEventBridgeTest {
)
val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
bridge.replaySnapshot()
val snapshot = sent[0] as ServerMessage.SessionSnapshot
assertEquals(3L, snapshot.lastSessionSequence)
@@ -240,6 +259,8 @@ class SessionEventBridgeTest {
noopArtifactStore,
activeOrchestrationRepository(),
approvalRepository,
noopWorkflowRegistry,
noopToolRegistry,
) { sent.add(it) }
channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
@@ -3,6 +3,7 @@ package com.correx.apps.server.ws
import com.correx.apps.server.bridge.DomainEventMapper
import com.correx.apps.server.bridge.SessionEventBridge
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
@@ -21,6 +22,8 @@ import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.sessions.projections.replay.EventReplayer
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancelAndJoin
@@ -103,6 +106,16 @@ class GlobalStreamHandlerTest {
},
)
private val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<com.correx.apps.server.registry.WorkflowSummary> = emptyList()
override fun find(workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? = null
}
private val noopToolRegistry = object : ToolRegistry {
override fun resolve(name: String): Tool? = null
override fun all(): List<Tool> = emptyList()
}
private fun fakeApprovalRepository(store: EventStore): DefaultApprovalRepository =
DefaultApprovalRepository(
DefaultEventReplayer(
@@ -121,7 +134,7 @@ class GlobalStreamHandlerTest {
): Pair<SessionEventBridge, Channel<ServerMessage>> {
val received = Channel<ServerMessage>(Channel.UNLIMITED)
val approvals = fakeApprovalRepository(store)
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, approvals) { received.send(it) }
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, approvals, noopWorkflowRegistry, noopToolRegistry) { received.send(it) }
return bridge to received
}
@@ -168,7 +181,7 @@ class GlobalStreamHandlerTest {
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L)
val approvals = fakeApprovalRepository(store)
val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) {
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) {
received.send(it)
}
val mapper = DomainEventMapper(noopArtifactStore)
@@ -211,7 +224,7 @@ class GlobalStreamHandlerTest {
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
val approvals = fakeApprovalRepository(store)
val received = Channel<ServerMessage>(Channel.UNLIMITED)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) {
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) {
received.send(it)
}
val mapper = DomainEventMapper(noopArtifactStore)
@@ -260,7 +273,7 @@ class GlobalStreamHandlerTest {
}
}
val approvals = fakeApprovalRepository(store)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { }
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { }
val mapper = DomainEventMapper(noopArtifactStore)
val job = launch {
@@ -278,7 +291,7 @@ class GlobalStreamHandlerTest {
val liveFlow = MutableSharedFlow<StoredEvent>()
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
val approvals = fakeApprovalRepository(store)
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { }
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { }
val mapper = DomainEventMapper(noopArtifactStore)
val job = launch {
@@ -6,15 +6,15 @@ sealed class KeyEvent {
object Cancel : KeyEvent()
object Approve : KeyEvent()
object Reject : KeyEvent()
object Steer : KeyEvent()
object NavUp : KeyEvent()
object NavDown : KeyEvent()
object Filter : KeyEvent()
object Tab : KeyEvent()
object ToggleApprovalOverlay : KeyEvent()
object ToggleEventOverlay : 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()
}
@@ -1,18 +1,21 @@
package com.correx.apps.tui
import com.correx.apps.tui.components.activeSessionWidget
import com.correx.apps.tui.components.approvalSurfaceWidget
import com.correx.apps.tui.components.eventHistoryStripWidget
import com.correx.apps.tui.components.inputBarWidget
import com.correx.apps.tui.components.routerPanelWidget
import com.correx.apps.tui.components.sessionListWidget
import com.correx.apps.tui.components.statusBarWidget
import com.correx.apps.tui.components.toolsPanelWidget
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.input.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
@@ -31,7 +34,7 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private const val DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 1
private const val TOP_ROW_HEIGHT = 9
private const val EVENT_STRIP_HEIGHT = 4
private const val INPUT_HEIGHT = 4
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
@@ -90,7 +93,10 @@ fun main(args: Array<String>) {
val handler = EventHandler { event, _ ->
if (event is TambouiKeyEvent) {
mapKey(event)
?.let { KeyResolver.resolve(it, state.inputMode, state.inputBuffer) }
?.let { key ->
val hasPendingApproval = state.selectedPendingApproval() != null
KeyResolver.resolve(key, state.displayState, state.inputMode, hasPendingApproval, state.inputBuffer)
}
?.let(::dispatch)
}
true
@@ -110,29 +116,72 @@ internal suspend fun dispatchAll(dispatcher: EffectDispatcher, effects: List<Eff
}
private fun render(frame: Frame, state: TuiState) {
val area = frame.area()
when (state.displayState) {
DisplayState.IDLE -> renderIdleLayout(frame, state)
DisplayState.IN_SESSION -> renderInSessionLayout(frame, state)
DisplayState.APPROVAL -> renderApprovalLayout(frame, state)
}
}
private fun renderIdleLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val vertRects = Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.length(TOP_ROW_HEIGHT),
Constraint.fill(),
Constraint.length(INPUT_HEIGHT),
)
.split(area)
val topRects = Layout.horizontal()
.constraints(
Constraint.percentage(30),
Constraint.percentage(30),
Constraint.percentage(40),
)
.split(vertRects[1])
frame.renderWidget(statusBarWidget(state), vertRects[0])
frame.renderWidget(sessionListWidget(state), topRects[0])
frame.renderWidget(activeSessionWidget(state), topRects[1])
frame.renderWidget(toolsPanelWidget(state), topRects[2])
frame.renderWidget(routerPanelWidget(state), vertRects[2])
frame.renderWidget(inputBarWidget(state), vertRects[3])
frame.renderWidget(sessionListWidget(state), vertRects[1])
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[2])
}
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
)
if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
val vertRects = Layout.vertical()
.constraints(constraints)
.split(area)
var rectIdx = 0
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
if (state.eventStripVisible) {
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx])
}
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
)
if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
val vertRects = Layout.vertical()
.constraints(constraints)
.split(area)
var rectIdx = 0
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
if (state.eventStripVisible) {
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), vertRects[rectIdx])
}
@@ -1,53 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
private const val LABEL_WIDTH = 8
fun activeSessionWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val session = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val lines = if (session == null) {
listOf(Line.from(Span.styled("no session selected", dimStyle)))
} else {
buildList<Line> {
val stageVal = session.currentStageId ?: session.currentStage ?: ""
add(labelRow("stage", Span.raw(stageVal)))
add(labelRow("status", statusSpanFor(session)))
add(labelRow("model", Span.styled(state.currentModel ?: "", Style.create().cyan())))
session.nextStageId?.let { add(labelRow("next", Span.styled(it, dimStyle))) }
}
}
val block = Block.builder()
.title("active session")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
private fun labelRow(label: String, value: Span): Line =
Line.from(Span.raw(label.padEnd(LABEL_WIDTH)), value)
private fun statusSpanFor(s: SessionSummary): Span {
val label = s.status.uppercase()
return when (s.status.lowercase()) {
"active", "running" -> Span.styled(label, Style.create().green())
"paused" -> Span.styled(label, Style.create().yellow())
"failed", "error" -> Span.styled(label, Style.create().red())
"completed", "done" -> Span.styled(label, Style.create().dim().gray())
else -> Span.raw(label)
}
}
@@ -1,32 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.ApprovalInfo
import dev.tamboui.style.Color
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
fun approvalPanelWidget(approval: ApprovalInfo): Paragraph {
val dimStyle = Style.create().dim().gray()
val lines = buildList<Line> {
add(Line.from(Span.styled("⚠ APPROVAL REQUIRED — Tier ${approval.tier}", Style.create().yellow().bold())))
approval.toolName?.let { add(Line.from(Span.raw("tool: $it"))) }
if (approval.riskSummary.isNotEmpty()) {
add(Line.from(Span.raw("risk: ${approval.riskSummary}")))
}
approval.preview?.let { add(Line.from(Span.raw("preview: $it"))) }
add(Line.from(Span.styled("[A] approve [R] reject [S] steer [D] details", dimStyle)))
}
val block = Block.builder()
.title("approval prompt")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.borderColor(Color.YELLOW)
.build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
@@ -0,0 +1,84 @@
package com.correx.apps.tui.components
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.selectedPendingApproval
import dev.tamboui.style.Color
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.block.Title
import dev.tamboui.widgets.paragraph.Paragraph
private const val PREVIEW_MAX_LINES = 20
fun approvalSurfaceWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val approval = state.selectedPendingApproval()
val pendingDecision = state.pendingDecision
val lines = buildList<Line> {
if (approval == null) {
add(Line.from(Span.styled("no pending approval", dimStyle)))
} else {
// Tool name, tier badge, risk summary
val toolName = approval.toolName ?: "(unknown tool)"
val toolSpan = Span.styled(toolName, Style.create())
val tierBadge = Span.styled("T${approval.tier}", Style.create().yellow())
val riskSpan = Span.styled(approval.riskSummary, dimStyle)
add(Line.from(toolSpan, Span.raw(" "), tierBadge, Span.raw(" "), riskSpan))
// Command preview (monospace, max 20 lines, truncation notice)
if (approval.preview != null) {
val previewLines = approval.preview.lines()
if (previewLines.isNotEmpty()) {
val visibleLines = previewLines.take(PREVIEW_MAX_LINES)
for (line in visibleLines) {
add(Line.from(Span.raw(" $line")))
}
if (previewLines.size > PREVIEW_MAX_LINES) {
val remaining = previewLines.size - PREVIEW_MAX_LINES
add(Line.from(Span.styled(" (preview truncated, $remaining more lines)", dimStyle)))
}
} else {
add(Line.from(Span.styled(" no preview available", dimStyle)))
}
} else {
add(Line.from(Span.styled(" no preview available", dimStyle)))
}
// Steering note input indicator
add(Line.from(Span.styled(" steering note: <type in input bar>", dimStyle)))
// Pending decision indicator (color coded)
val decisionSpan = when (pendingDecision) {
null -> Span.styled(" decision: \u2014", dimStyle)
ApprovalDecision.APPROVE -> Span.styled(" decision: APPROVE", Style.create().green())
ApprovalDecision.REJECT -> Span.styled(" decision: REJECT", Style.create().red())
}
add(Line.from(decisionSpan))
// Approve/reject key hints
add(Line.from(Span.styled(
" ctrl+a approve \u00B7 ctrl+r reject \u00B7 enter submit \u00B7 esc dismiss",
dimStyle
)))
}
}
val block = Block.builder()
.title(Title.from(Span.styled("approval", Style.create().yellow())).centered())
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.borderColor(Color.YELLOW)
.build()
return Paragraph.builder()
.text(Text.from(lines))
.block(block)
.build()
}
@@ -0,0 +1,86 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.ToolDisplayStatus
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.paragraph.Paragraph
private const val DETAIL_MAX = 60
private const val TYPE_WIDTH = 10
fun eventHistoryStripWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val lines = buildList<Line> {
if (selectedSession == null) {
add(Line.from(Span.styled("no session selected", dimStyle)))
} else {
// Stage ID and status line (compact, dim metadata)
val stageInfo = buildString {
if (selectedSession.currentStageId != null) {
append(selectedSession.currentStageId)
}
if (selectedSession.currentStage != null) {
if (isNotEmpty()) append(" · ")
append(selectedSession.currentStage)
}
if (isEmpty()) append("(no stage)")
append(" ")
append(selectedSession.status)
}
add(Line.from(Span.styled(stageInfo, dimStyle)))
// Recent events (plain text, compact format)
val events = selectedSession.recentEvents
if (events.isEmpty()) {
add(Line.from(Span.styled("no events", dimStyle)))
} else {
for (ev in events) {
val typePadded = ev.type.padEnd(TYPE_WIDTH, ' ')
val detail = if (ev.detail.length > DETAIL_MAX) {
ev.detail.take(DETAIL_MAX - 3) + "..."
} else {
ev.detail
}
val row = "[${ev.timestamp}] [$typePadded] $detail"
add(Line.from(Span.raw(row)))
}
}
// Tool manifest for current stage (from toolsByStage)
val stageId = selectedSession.currentStageId
if (stageId != null) {
val tools = selectedSession.toolsByStage[stageId]
if (!tools.isNullOrEmpty()) {
add(Line.from(Span.styled("declared tools:", dimStyle)))
for (tool in tools) {
add(Line.from(Span.raw(" ${tool.name} T${tool.tier}")))
}
}
}
// Runtime tool records (TuiToolRecord from SessionSummary.tools)
if (selectedSession.tools.isNotEmpty()) {
add(Line.from(Span.styled("tool records:", dimStyle)))
for (tool in selectedSession.tools) {
val statusSymbol = when (tool.status) {
ToolDisplayStatus.REQUESTED -> "?"
ToolDisplayStatus.STARTED -> "\u25B6"
ToolDisplayStatus.COMPLETED -> "\u2713"
ToolDisplayStatus.FAILED -> "\u2717"
ToolDisplayStatus.REJECTED -> "\u2298"
}
add(Line.from(Span.raw(" $statusSymbol ${tool.name} T${tool.tier}")))
}
}
}
}
return Paragraph.builder()
.text(Text.from(lines))
.build()
}
@@ -1,5 +1,7 @@
package com.correx.apps.tui.components
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
@@ -11,10 +13,8 @@ import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
fun inputBarWidget(state: TuiState): Paragraph {
fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph {
val dimStyle = Style.create().dim().gray()
val hasApproval = state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval != null
val hasSession = state.sessions.selectedId != null
val sessionName = state.sessions.sessions
.firstOrNull { it.id == state.sessions.selectedId }
@@ -22,12 +22,21 @@ fun inputBarWidget(state: TuiState): Paragraph {
?: state.sessions.selectedId?.take(6)
?: "(no session)"
val (row1, row2) = when (state.inputMode) {
InputMode.ROUTER -> createRowsForRouter(state, dimStyle, sessionName, hasApproval, hasSession)
InputMode.NAVIGATE -> createRowsForNavigate(dimStyle, sessionName, hasApproval, hasSession)
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
InputMode.STEER -> createRowsForSteer(state, dimStyle, sessionName)
val (row1, row2) = when (displayState) {
DisplayState.IDLE -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForIdleRouter(state, dimStyle, sessionName)
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
}
DisplayState.IN_SESSION -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForInSessionRouter(state, dimStyle, sessionName)
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
}
DisplayState.APPROVAL -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForApprovalRouter(state, dimStyle)
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
}
}
val block = Block.builder()
@@ -39,7 +48,7 @@ fun inputBarWidget(state: TuiState): Paragraph {
return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build()
}
private fun createRowsForSteer(
private fun createRowsForIdleRouter(
state: TuiState,
dimStyle: Style?,
sessionName: String,
@@ -47,9 +56,50 @@ private fun createRowsForSteer(
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Steer the session…", dimStyle))
Line.from(Span.raw(""), Span.styled("Session name", dimStyle))
}
return cursor to Line.from(Span.styled("$sessionName · steering esc cancel enter send", dimStyle))
return cursor to Line.from(Span.styled(
"$sessionName · router tab filter ctrl+n new ctrl+e events ctrl+q",
dimStyle,
))
}
private fun createRowsForInSessionRouter(
state: TuiState,
dimStyle: Style?,
sessionName: String,
): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Ask anything…", dimStyle))
}
return cursor to Line.from(Span.styled(
"$sessionName · router tab filter ↑↓ history ctrl+e events ctrl+l back ctrl+q",
dimStyle,
))
}
private fun createRowsForApprovalRouter(
state: TuiState,
dimStyle: Style?,
): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Steering note (optional)…", dimStyle))
}
val decisionIndicator = when (state.pendingDecision) {
null -> ""
ApprovalDecision.APPROVE -> " [decision: APPROVE]"
ApprovalDecision.REJECT -> " [decision: REJECT]"
}
return cursor to Line.from(Span.styled(
"approval · ctrl+a approve ctrl+r reject ↵ enter submit esc dismiss$decisionIndicator",
dimStyle,
))
}
private fun createRowsForFilter(
@@ -63,48 +113,3 @@ private fun createRowsForFilter(
}
return cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle))
}
private fun createRowsForNavigate(
dimStyle: Style?,
sessionName: String,
hasApproval: Boolean,
hasSession: Boolean,
): Pair<Line?, Line?> {
val row1Line = Line.from(Span.styled(" ↑↓ sessions enter select", dimStyle))
val hints = buildList {
add("$sessionName · navigate")
add("tab router")
if (hasApproval) {
add("ctrl+a approve"); add("ctrl+r reject")
}
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
return row1Line to Line.from(Span.styled(hints, dimStyle))
}
private fun createRowsForRouter(
state: TuiState,
dimStyle: Style?,
sessionName: String,
hasApproval: Boolean,
hasSession: Boolean,
): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Ask anything…", dimStyle))
}
val hints = buildList {
add("$sessionName · router")
add("tab navigate")
if (hasApproval) {
add("ctrl+a approve"); add("ctrl+r reject")
}
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
return cursor to Line.from(Span.styled(hints, dimStyle))
}
@@ -1,7 +1,6 @@
package com.correx.apps.tui.components
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
@@ -14,7 +13,7 @@ import dev.tamboui.widgets.paragraph.Paragraph
fun routerPanelWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val mainLines = buildList<Line> {
val lines = buildList<Line> {
if (!state.routerConnected) {
add(Line.from(Span.styled("not connected — epic 14", dimStyle)))
} else {
@@ -24,44 +23,11 @@ fun routerPanelWidget(state: TuiState): Paragraph {
}
}
val activeApproval = state.selectedPendingApproval()
val overlayLines: List<Line> = when {
state.approvalOverlayVisible && activeApproval != null -> {
buildList {
add(Line.from(Span.styled("╭─ approval [alt+h to hide] ─────────────╮", Style.create().yellow())))
val tierLine = "│ ⚠ ${activeApproval.tier}${activeApproval.toolName ?: "no tool name"}${
activeApproval.riskSummary.take(12)
}... "
add(Line.from(Span.styled(tierLine, Style.create().yellow())))
val previewLine = "${(activeApproval.preview ?: "no preview").take(40)}… │"
add(Line.from(Span.styled(previewLine, dimStyle)))
add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().yellow())))
}
}
state.eventOverlayVisible -> {
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val events = selectedSession?.recentEvents ?: emptyList()
buildList {
add(Line.from(Span.styled("╭─ events [ctrl+e to hide] ───────────────╮", Style.create().magenta())))
for (ev in events) {
val row = "${ev.timestamp} ${ev.type} ${ev.detail}"
add(Line.from(Span.styled(row, dimStyle)))
}
add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().magenta())))
}
}
else -> emptyList()
}
val allLines = mainLines + overlayLines
val block = Block.builder()
.title("router")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(allLines)).block(block).build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
@@ -1,7 +1,9 @@
package com.correx.apps.tui.components
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.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
@@ -10,12 +12,12 @@ import dev.tamboui.widgets.paragraph.Paragraph
fun statusBarWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val sep = Span.styled(" ", dimStyle)
val sep = Span.styled(" \u2502 ", dimStyle)
val connSpan = if (state.connection.connected) {
Span.styled("", Style.create().green())
Span.styled("\u25CF", Style.create().green())
} else {
Span.styled("", Style.create().dim().red())
Span.styled("\u25CB", Style.create().dim().red())
}
val connLabel = if (state.connection.connected) Span.raw(" connected") else Span.raw(" disconnected")
@@ -27,6 +29,37 @@ fun statusBarWidget(state: TuiState): Paragraph {
val sessionsSpan = Span.styled("${state.sessions.sessions.size} sessions", dimStyle)
// Background update badge (+N updated, dim yellow, only when displayState != IDLE)
val backgroundBadge = if (state.displayState != DisplayState.IDLE && state.sessions.backgroundUpdateCount > 0) {
Span.styled("+${state.sessions.backgroundUpdateCount} updated", Style.create().dim().yellow())
} else {
null
}
// Compact tool manifest for selected session's current stage
val toolManifestSpan = run {
val selected = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
if (selected != null) {
val stageId = selected.currentStageId
if (stageId != null) {
val tools = selected.toolsByStage[stageId]
if (tools != null && tools.isNotEmpty()) {
val text = tools.joinToString(" \u00B7 ") { tool ->
val suffix = if (tool.tier >= 2) "\u26A0" else ""
"${tool.name} T${tool.tier}$suffix"
}
Span.styled(text, dimStyle)
} else {
Span.styled("tools: \u2014", dimStyle)
}
} else {
null
}
} else {
null
}
}
val spans = buildList<Span> {
add(connSpan)
add(connLabel)
@@ -36,9 +69,17 @@ fun statusBarWidget(state: TuiState): Paragraph {
add(modelSpan)
add(sep)
add(sessionsSpan)
if (backgroundBadge != null) {
add(sep)
add(backgroundBadge)
}
if (toolManifestSpan != null) {
add(sep)
add(toolManifestSpan)
}
if (state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval != null) {
add(sep)
add(Span.styled("[ approval]", Style.create().yellow()))
add(Span.styled("[\u26A0 approval]", Style.create().yellow()))
}
}
@@ -1,47 +0,0 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.ToolDisplayStatus
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
private const val ARGS_MAX = 30
fun toolsPanelWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val amberStyle = Style.create().yellow()
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val tools = selectedSession?.tools ?: emptyList()
val lines = buildList<Line> {
for (tool in tools) {
val warn = tool.tier >= 2 || tool.status == ToolDisplayStatus.REQUESTED
val nameSpans = buildList<Span> {
add(Span.raw(tool.name))
if (warn) {
add(Span.raw(" "))
add(Span.styled("", amberStyle))
}
}
add(Line.from(nameSpans))
tool.argsPreview?.let { args ->
val truncated = if (args.length > ARGS_MAX) args.take(ARGS_MAX - 1) + "" else args
add(Line.from(Span.styled(" $truncated", dimStyle)))
}
}
}
val block = Block.builder()
.title("tools")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(Text.from(lines)).block(block).build()
}
@@ -8,7 +8,6 @@ sealed interface Action {
data object CancelSelectedSession : Action
data object ApproveActive : Action
data object RejectActive : Action
data object OpenSteeringPrompt : Action
data object NavigateUp : Action
data object NavigateDown : Action
data object OpenFilter : Action
@@ -16,13 +15,14 @@ sealed interface 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 ToggleApprovalOverlay : Action
data object ToggleEventOverlay : Action
data object EnterSteer : Action
data object CycleMode : Action
data object NoOp : Action
}
@@ -1,56 +1,71 @@
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, mode: InputMode, inputText: String): Action? {
val global = resolveGlobal(key)
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
else -> null
}
if (global != null) return global
return when (mode) {
InputMode.ROUTER -> resolveRouterMode(key, inputText)
InputMode.NAVIGATE -> resolveNavigateMode(key)
InputMode.FILTER, InputMode.STEER -> resolveTextMode(key, inputText)
// 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 -> if (inputText.isBlank()) null else Action.SubmitInput
KeyEvent.Tab -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null
}
DisplayState.IN_SESSION -> when (key) {
KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
KeyEvent.Tab -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
KeyEvent.ShowPendingApproval -> if (hasPendingApproval) Action.ShowPendingApproval else null
else -> null
}
DisplayState.APPROVAL -> when (key) {
KeyEvent.Enter -> Action.SubmitApprovalDecision
KeyEvent.Escape -> Action.CancelInput
KeyEvent.Tab -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null
}
}
}
private fun resolveGlobal(key: KeyEvent): Action? = when (key) {
KeyEvent.ToggleApprovalOverlay -> Action.ToggleApprovalOverlay
KeyEvent.ToggleEventOverlay -> Action.ToggleEventOverlay
else -> null
}
private fun resolveRouterMode(key: KeyEvent, inputText: String): Action? = when (key) {
KeyEvent.Quit -> Action.Quit
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
KeyEvent.Cancel -> Action.CancelSelectedSession
KeyEvent.Approve -> Action.ApproveActive
KeyEvent.Reject -> Action.RejectActive
KeyEvent.Steer -> Action.OpenSteeringPrompt
KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Filter -> Action.CycleMode
KeyEvent.Backspace -> Action.Backspace
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null
}
private fun resolveNavigateMode(key: KeyEvent): Action? = when (key) {
KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Enter -> Action.SubmitInput
KeyEvent.Filter -> Action.CycleMode
KeyEvent.Escape -> Action.CancelInput
else -> null
}
private fun resolveTextMode(key: KeyEvent, inputText: String): Action? = when (key) {
KeyEvent.Filter -> Action.CycleMode
KeyEvent.Escape -> Action.CancelInput
KeyEvent.Backspace -> Action.Backspace
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
else -> null
}
}
@@ -10,15 +10,8 @@ private val log = LoggerFactory.getLogger("com.correx.apps.tui.input")
@Suppress("CyclomaticComplexMethod", "ReturnCount")
fun mapKey(event: TambouiKeyEvent): KeyEvent? {
log.debug("got event: {}", event)
if (event.isCtrlC) return KeyEvent.Quit
if (event.hasAlt()) {
return when {
event.isChar('h') -> KeyEvent.ToggleApprovalOverlay.also {
log.debug("got event for toggle approval")
}
else -> null
}
return null
}
if (event.hasCtrl()) {
return when {
@@ -27,10 +20,9 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
event.isChar('c') -> KeyEvent.Cancel
event.isChar('a') -> KeyEvent.Approve
event.isChar('r') -> KeyEvent.Reject
event.isChar('s') -> KeyEvent.Steer
event.isChar('e') -> KeyEvent.ToggleEventOverlay
event.isChar('l') -> KeyEvent.ReturnToSessionList
event.isChar('e') -> KeyEvent.ToggleEventStrip
event.isChar('h') -> KeyEvent.ShowPendingApproval
else -> null
}
}
@@ -40,7 +32,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
KeyCode.ENTER -> KeyEvent.Enter
KeyCode.ESCAPE -> KeyEvent.Escape
KeyCode.BACKSPACE -> KeyEvent.Backspace
KeyCode.TAB -> KeyEvent.Filter
KeyCode.TAB -> KeyEvent.Tab
KeyCode.CHAR -> {
val ch = event.character()
if (ch.isISOControl()) null else KeyEvent.CharInput(ch)
@@ -16,75 +16,75 @@ object InputReducer {
): Pair<TuiState, List<Effect>> = when (action) {
is Action.CycleMode -> state.copy(
inputMode = when (state.inputMode) {
InputMode.ROUTER -> InputMode.NAVIGATE
InputMode.NAVIGATE -> InputMode.FILTER
InputMode.ROUTER -> InputMode.FILTER
InputMode.FILTER -> InputMode.ROUTER
InputMode.STEER -> InputMode.ROUTER
},
) to emptyList()
is Action.OpenNewSessionPrompt -> state.copy(
inputMode = InputMode.ROUTER,
inputBuffer = "",
) to emptyList()
is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
is Action.OpenSteeringPrompt -> {
is Action.AppendChar -> state.copy(
inputBuffer = state.inputBuffer + action.ch,
) to emptyList()
is Action.Backspace -> state.copy(
inputBuffer = state.inputBuffer.dropLast(1),
) to emptyList()
is Action.CancelInput -> state.copy(
inputMode = InputMode.ROUTER,
inputBuffer = "",
) to emptyList()
is Action.SubmitInput -> state.copy(
inputBuffer = "",
) to emptyList()
is Action.SubmitApprovalDecision -> {
val decision = state.pendingDecision ?: ApprovalDecision.REJECT
val active = state.selectedPendingApproval()
val steeringNote = state.inputBuffer.ifBlank { null }
if (active != null) {
state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
} else {
state to emptyList()
}
}
is Action.EnterSteer -> state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
is Action.OpenFilter -> state.copy(inputMode = InputMode.FILTER, inputBuffer = "") to emptyList()
is Action.AppendChar -> state.copy(inputBuffer = state.inputBuffer + action.ch) to emptyList()
is Action.Backspace -> state.copy(inputBuffer = state.inputBuffer.dropLast(1)) to emptyList()
is Action.CancelInput -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
is Action.SubmitInput -> when (state.inputMode) {
InputMode.ROUTER -> {
val text = state.inputBuffer.trim()
if (text.isNotBlank()) {
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to listOf(
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)),
)
} else {
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
}
}
InputMode.STEER -> {
val text = state.inputBuffer.trim()
val active = state.selectedPendingApproval()
if (active != null && text.isNotBlank()) {
val selectedId = state.sessions.selectedId
val clearedSessions = state.sessions.copy(
sessions = state.sessions.sessions.map { s ->
if (s.id == selectedId) s.copy(pendingApproval = null) else s
},
)
state.copy(
inputMode = InputMode.ROUTER,
inputBuffer = "",
sessions = clearedSessions,
) to listOf(
Effect.SendWs(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(active.requestId),
// TODO: hardcoded APPROVE until the approve/reject overlay UX lands;
// then the user picks the decision explicitly alongside the note.
decision = ApprovalDecision.APPROVE,
steeringNote = text,
),
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(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
}
),
)
} else {
state.copy(
pendingDecision = null,
approvalDismissed = false,
inputBuffer = "",
) to emptyList()
}
InputMode.FILTER -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
InputMode.NAVIGATE -> state to emptyList()
}
is Action.ShowPendingApproval -> state.copy(
approvalDismissed = false,
) to emptyList()
is Action.ReturnToSessionList -> state.copy(
sessions = state.sessions.copy(selectedId = null),
) to emptyList()
else -> state to emptyList()
}
}
@@ -1,8 +1,12 @@
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.TuiState
import com.correx.apps.tui.state.displayState
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(RootReducer::class.java.name)
@@ -36,6 +40,7 @@ object RootReducer {
return current to allEffects
}
@Suppress("CyclomaticComplexMethod")
private fun reduceThroughSubReducers(
state: TuiState,
action: Action,
@@ -45,41 +50,150 @@ object RootReducer {
// 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(afterInput.sessions, prevInputMode, prevInputBuffer, action, clock)
val (sessions, se) = SessionsReducer.reduce(
sessions = afterInput.sessions,
displayState = afterInput.displayState,
inputMode = prevInputMode,
inputText = prevInputBuffer,
action = action,
clock = clock,
)
log.debug("connection state before reducers={}", state.connection)
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
log.debug("provider state before reducers={}", state.provider)
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
val selectedPending = sessions.sessions.firstOrNull { it.id == sessions.selectedId }?.pendingApproval
val prevSelectedPending = afterInput.sessions.sessions.firstOrNull { it.id == afterInput.sessions.selectedId }?.pendingApproval
val approvalJustArrived = selectedPending != null && prevSelectedPending == null
// ── 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,
approvalOverlayVisible = if (approvalJustArrived) true else afterInput.approvalOverlayVisible,
)
val final = when (action) {
is Action.ToggleApprovalOverlay -> withSubReducers.copy(
approvalOverlayVisible = !withSubReducers.approvalOverlayVisible,
eventOverlayVisible = false,
)
is Action.ToggleEventOverlay -> withSubReducers.copy(
eventOverlayVisible = !withSubReducers.eventOverlayVisible,
approvalOverlayVisible = false,
)
else -> withSubReducers
// 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)
}
is Action.ToggleEventStrip -> {
if (withBgReset.displayState == DisplayState.IDLE) withBgReset
else withBgReset.copy(eventStripVisible = !withBgReset.eventStripVisible)
}
// Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1).
is Action.SubmitInput -> {
val text = prevInputBuffer.trim()
if (text.isNotBlank() && sessions.selectedId != null &&
prevInputMode == InputMode.ROUTER &&
withBgReset.displayState == com.correx.apps.tui.state.DisplayState.IN_SESSION
) {
val selectedId = sessions.selectedId!!
val currentHistory = withBgReset.inputHistory[selectedId] ?: emptyList()
val updatedHistory = (currentHistory + text).takeLast(50)
withBgReset.copy(
inputHistory = withBgReset.inputHistory + (selectedId to updatedHistory),
)
} else {
withBgReset
}
}
// Input history navigation on NavigateUp in IN_SESSION + ROUTER (ARCH-HISTORY-2/3).
is Action.NavigateUp -> {
if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) {
val selectedId = withBgReset.sessions.selectedId
if (selectedId != null) {
val history = withBgReset.inputHistory[selectedId] ?: emptyList()
if (history.isNotEmpty()) {
when (withBgReset.inputHistoryIndex) {
-1 -> withBgReset.copy(
savedInputBuffer = withBgReset.inputBuffer,
inputHistoryIndex = history.size - 1,
inputBuffer = history[history.size - 1],
)
0 -> withBgReset // at oldest entry, stay
else -> {
val newIndex = withBgReset.inputHistoryIndex - 1
withBgReset.copy(
inputHistoryIndex = newIndex,
inputBuffer = history[newIndex],
)
}
}
} else {
withBgReset
}
} else {
withBgReset
}
} else {
withBgReset
}
}
// Input history navigation on NavigateDown in IN_SESSION + ROUTER (ARCH-HISTORY-2/3).
is Action.NavigateDown -> {
if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) {
val selectedId = withBgReset.sessions.selectedId
if (selectedId != null) {
val history = withBgReset.inputHistory[selectedId] ?: emptyList()
when (withBgReset.inputHistoryIndex) {
-1 -> withBgReset // already at current buffer
history.size - 1 -> withBgReset.copy(
inputHistoryIndex = -1,
inputBuffer = withBgReset.savedInputBuffer,
)
else -> {
val newIndex = withBgReset.inputHistoryIndex + 1
withBgReset.copy(
inputHistoryIndex = newIndex,
inputBuffer = history[newIndex],
)
}
}
} else {
withBgReset
}
} else {
withBgReset
}
}
else -> withBgReset
}
val combined = ie + se + ce + pe
val withTerminal = if (action is Action.Quit) combined + Effect.Quit else combined
return final to withTerminal
@@ -1,15 +1,16 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.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.ApprovalRequestId
@@ -26,27 +27,44 @@ object SessionsReducer {
fun reduce(
sessions: SessionsState,
displayState: DisplayState,
inputMode: InputMode,
inputText: String,
action: Action,
clock: () -> Long = System::currentTimeMillis,
): Pair<SessionsState, List<Effect>> = when (action) {
is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) {
navigateUp(sessions) to emptyList()
} else {
sessions to emptyList()
is Action.NavigateUp -> when {
inputMode == InputMode.FILTER -> navigateUp(sessions) to emptyList()
displayState == DisplayState.IDLE -> navigateUp(sessions) to emptyList()
displayState == DisplayState.IN_SESSION -> sessions to emptyList()
else -> sessions to emptyList()
}
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) {
navigateDown(sessions) to emptyList()
} else {
sessions to emptyList()
is Action.NavigateDown -> when {
inputMode == InputMode.FILTER -> navigateDown(sessions) to emptyList()
displayState == DisplayState.IDLE -> navigateDown(sessions) to emptyList()
displayState == DisplayState.IN_SESSION -> sessions to emptyList()
else -> sessions to emptyList()
}
is Action.SubmitInput -> if (inputMode == InputMode.FILTER) {
sessions.copy(filter = inputText) to emptyList()
} else {
sessions to emptyList()
is Action.SubmitInput -> when {
inputMode == InputMode.FILTER -> sessions.copy(filter = inputText) to emptyList()
displayState == DisplayState.IDLE -> {
val text = inputText.trim()
if (text.isNotBlank()) {
sessions to listOf(
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)),
)
} else {
sessions to emptyList()
}
}
displayState == DisplayState.IN_SESSION -> {
// Chat send is blocked until Epic 14 (router integration).
// History append is handled by RootReducer cross-field weave.
sessions to emptyList()
}
else -> sessions to emptyList()
}
is Action.CancelInput -> if (inputMode == InputMode.FILTER) {
@@ -64,36 +82,10 @@ object SessionsReducer {
}
}
is Action.ApproveActive -> respondToSelectedApproval(sessions, ApprovalDecision.APPROVE, note = null)
is Action.RejectActive -> respondToSelectedApproval(sessions, ApprovalDecision.REJECT, note = null)
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
else -> sessions to emptyList()
}
private fun respondToSelectedApproval(
sessions: SessionsState,
decision: ApprovalDecision,
note: String?,
): Pair<SessionsState, List<Effect>> {
val selectedId = sessions.selectedId ?: return sessions to emptyList()
val selected = sessions.sessions.firstOrNull { it.id == selectedId } ?: return sessions to emptyList()
val pending = selected.pendingApproval ?: return sessions to emptyList()
val updated = sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == selectedId) s.copy(pendingApproval = null) else s
},
)
val effect = Effect.SendWs(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(pending.requestId),
decision = decision,
steeringNote = note,
),
)
return updated to listOf(effect)
}
private fun filteredSessions(sessions: SessionsState): List<SessionSummary> =
if (sessions.filter.isBlank()) sessions.sessions
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
@@ -122,23 +114,91 @@ object SessionsReducer {
sessions: SessionsState,
msg: ServerMessage,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> = when (msg) {
is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions)
is ServerMessage.SessionPaused -> processSessionPausedMessage(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)
else -> sessions to emptyList()
}.also { log.debug("processed server message: {}", msg) }
): 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.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)
else -> sessions to emptyList()
}
val withBg = incrementBackgroundCountIfNonSelected(result, msg)
log.debug("processed server message: {}", msg)
return withBg to effects
}
private fun sessionIdFromMessage(msg: ServerMessage): String? = when (msg) {
is ServerMessage.SessionStarted -> msg.sessionId.value
is ServerMessage.SessionPaused -> msg.sessionId.value
is ServerMessage.SessionCompleted -> msg.sessionId.value
is ServerMessage.SessionFailed -> msg.sessionId.value
is ServerMessage.StageStarted -> msg.sessionId.value
is ServerMessage.StageCompleted -> msg.sessionId.value
is ServerMessage.StageFailed -> msg.sessionId.value
is ServerMessage.ToolStarted -> msg.sessionId.value
is ServerMessage.InferenceCompleted -> msg.sessionId.value
is ServerMessage.ToolCompleted -> msg.sessionId.value
is ServerMessage.ToolFailed -> msg.sessionId.value
is ServerMessage.ToolRejected -> msg.sessionId.value
is ServerMessage.ApprovalRequired -> msg.sessionId.value
is ServerMessage.SessionSnapshot -> msg.sessionId.value
// Control/non-event messages do not count per ARCH-BADGE-1:
is ServerMessage.StageToolManifest -> null
is ServerMessage.SnapshotComplete -> null
is ServerMessage.ProtocolError -> null
is ServerMessage.ProviderStatusChanged -> null
else -> null
}
private fun incrementBackgroundCountIfNonSelected(
sessions: SessionsState,
msg: ServerMessage,
): SessionsState {
val sessionId = sessionIdFromMessage(msg) ?: return sessions
return if (sessionId != sessions.selectedId) {
sessions.copy(backgroundUpdateCount = sessions.backgroundUpdateCount + 1)
} else {
sessions
}
}
/** TODO(RF-3): If StageToolManifest arrives before SessionStarted, the session
* won't exist in the list and the manifest is silently dropped. Options:
* (a) Buffer the manifest and apply when SessionStarted arrives for this sessionId.
* (b) Create a placeholder SessionSummary from the manifest's sessionId/workflowId.
* Option (b) also requires dedup in processSessionStartedMessage to avoid duplicates.
* Deferred — the spec says manifest arrives on session start, but WebSocket ordering
* is not guaranteed. */
private fun processStageToolManifestMessage(
sessions: SessionsState,
msg: ServerMessage.StageToolManifest,
): Pair<SessionsState, List<Effect>> {
val toolsByStage = msg.stages.associate { stageDecl ->
stageDecl.stageId to stageDecl.tools.map { toolDecl ->
ToolManifestEntry(name = toolDecl.name, tier = toolDecl.tier)
}
}
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(toolsByStage = toolsByStage)
} else {
s
}
},
) to emptyList()
}
private fun processSessionSnapshotMessage(
clock: () -> Long,
@@ -359,19 +419,21 @@ object SessionsReducer {
sessions: SessionsState,
msg: ServerMessage.SessionFailed,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> = touchSession(
sessions,
msg.sessionId.value,
"FAILED",
clock,
) to emptyList()
): 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>> =
touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to emptyList()
): Pair<SessionsState, List<Effect>> {
val result = touchSession(sessions, msg.sessionId.value, "COMPLETED", clock)
val cleared = if (msg.sessionId.value == result.selectedId) result.copy(selectedId = null) else result
return cleared to emptyList()
}
private fun processSessionPausedMessage(
msg: ServerMessage.SessionPaused,
@@ -395,6 +457,8 @@ object SessionsReducer {
clock: () -> Long,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
// TODO(RF-3): If StageToolManifest created a placeholder, dedup/replace instead of
// unconditional append to avoid duplicate session entries.
val summary = SessionSummary(
id = msg.sessionId.value,
status = "ACTIVE",
@@ -0,0 +1,15 @@
package com.correx.apps.tui.state
enum class DisplayState { IDLE, IN_SESSION, APPROVAL }
val TuiState.displayState: DisplayState
get() {
val selectedId = sessions.selectedId ?: return DisplayState.IDLE
val selectedSession = sessions.sessions.firstOrNull { it.id == selectedId }
val hasApproval = selectedSession?.pendingApproval != null
return when {
approvalDismissed -> DisplayState.IN_SESSION
hasApproval -> DisplayState.APPROVAL
else -> DisplayState.IN_SESSION
}
}
@@ -4,4 +4,5 @@ data class SessionsState(
val sessions: List<SessionSummary> = emptyList(),
val selectedId: String? = null,
val filter: String = "",
val backgroundUpdateCount: Int = 0,
)
@@ -1,8 +1,9 @@
package com.correx.apps.tui.state
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ServerMessage
enum class InputMode { ROUTER, NAVIGATE, FILTER, STEER }
enum class InputMode { ROUTER, FILTER }
enum class ToolDisplayStatus { REQUESTED, STARTED, COMPLETED, FAILED, REJECTED }
@@ -28,8 +29,12 @@ data class TuiState(
val provider: ProviderState = ProviderState(),
val inputMode: InputMode = InputMode.ROUTER,
val inputBuffer: String = "",
val approvalOverlayVisible: Boolean = false,
val eventOverlayVisible: Boolean = false,
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,
val currentModel: String? = null,
val providerType: ProviderType = ProviderType.LOCAL,
val routerConnected: Boolean = false,
@@ -51,6 +56,11 @@ data class TuiState(
val cursors: Map<String, Long> = emptyMap(),
)
data class ToolManifestEntry(
val name: String,
val tier: Int,
)
data class SessionSummary(
val id: String,
val status: String,
@@ -63,6 +73,7 @@ data class SessionSummary(
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,
)
@@ -1,117 +1,147 @@
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 {
@Test
fun `n in ROUTER mode resolves to OpenNewSessionPrompt`() {
val result = KeyResolver.resolve(KeyEvent.NewSession, InputMode.ROUTER, "")
assertEquals(Action.OpenNewSessionPrompt, result)
}
@Test
fun `command letters in text input mode resolve to null`() {
val result = KeyResolver.resolve(KeyEvent.NewSession, InputMode.FILTER, "")
assertNull(result)
}
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)
@Test
fun `CharInput in text input mode resolves to AppendChar`() {
val result = KeyResolver.resolve(KeyEvent.CharInput('a'), InputMode.FILTER, "")
assertEquals(Action.AppendChar('a'), result)
}
@Test
fun `Enter on blank input resolves to null`() {
val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.FILTER, "")
assertNull(result)
}
@Test
fun `Enter on non-blank input resolves to SubmitInput`() {
val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.FILTER, "test")
assertEquals(Action.SubmitInput, result)
}
@Test
fun `Escape in text input mode resolves to CancelInput`() {
val result = KeyResolver.resolve(KeyEvent.Escape, InputMode.FILTER, "")
assertEquals(Action.CancelInput, result)
}
@Test
fun `Backspace in text input mode resolves to Backspace action`() {
val result = KeyResolver.resolve(KeyEvent.Backspace, InputMode.FILTER, "")
assertEquals(Action.Backspace, result)
}
// ── Global actions (work regardless of display state / input mode) ──
@Test
fun `Quit in ROUTER mode resolves to Quit`() {
val result = KeyResolver.resolve(KeyEvent.Quit, InputMode.ROUTER, "")
assertEquals(Action.Quit, result)
assertEquals(Action.Quit, resolve(KeyEvent.Quit))
}
@Test
fun `Quit in text input mode resolves to null`() {
val result = KeyResolver.resolve(KeyEvent.Quit, InputMode.FILTER, "")
assertNull(result)
fun `Quit in FILTER mode resolves to Quit`() {
assertEquals(Action.Quit, resolve(KeyEvent.Quit, inputMode = InputMode.FILTER))
}
@Test
fun `Cancel in ROUTER mode resolves to CancelSelectedSession`() {
val result = KeyResolver.resolve(KeyEvent.Cancel, InputMode.ROUTER, "")
assertEquals(Action.CancelSelectedSession, result)
fun `Approve resolves globally to ApproveActive`() {
assertEquals(Action.ApproveActive, resolve(KeyEvent.Approve))
}
@Test
fun `Approve in ROUTER mode resolves to ApproveActive`() {
val result = KeyResolver.resolve(KeyEvent.Approve, InputMode.ROUTER, "")
assertEquals(Action.ApproveActive, result)
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 `Reject in ROUTER mode resolves to RejectActive`() {
val result = KeyResolver.resolve(KeyEvent.Reject, InputMode.ROUTER, "")
assertEquals(Action.RejectActive, result)
fun `NavDown in FILTER mode resolves to NavigateDown`() {
assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, inputMode = InputMode.FILTER))
}
@Test
fun `Steer in ROUTER mode resolves to OpenSteeringPrompt`() {
val result = KeyResolver.resolve(KeyEvent.Steer, InputMode.ROUTER, "")
assertEquals(Action.OpenSteeringPrompt, result)
fun `Enter in FILTER mode on blank input resolves to null`() {
assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER))
}
@Test
fun `NavUp in ROUTER mode resolves to NavigateUp`() {
val result = KeyResolver.resolve(KeyEvent.NavUp, InputMode.ROUTER, "")
assertEquals(Action.NavigateUp, result)
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 `NavDown in ROUTER mode resolves to NavigateDown`() {
val result = KeyResolver.resolve(KeyEvent.NavDown, InputMode.ROUTER, "")
assertEquals(Action.NavigateDown, result)
fun `Enter in FILTER mode on whitespace-only input resolves to null`() {
assertNull(resolve(KeyEvent.Enter, inputMode = InputMode.FILTER, inputText = " "))
}
@Test
fun `Filter in ROUTER mode cycles to NAVIGATE`() {
val result = KeyResolver.resolve(KeyEvent.Filter, InputMode.ROUTER, "")
assertEquals(Action.CycleMode, result)
fun `Escape in FILTER mode resolves to CancelInput`() {
assertEquals(Action.CancelInput, resolve(KeyEvent.Escape, inputMode = InputMode.FILTER))
}
@Test
fun `Enter on whitespace-only input resolves to null`() {
val result = KeyResolver.resolve(KeyEvent.Enter, InputMode.FILTER, " ")
assertNull(result)
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 null`() {
assertNull(resolve(KeyEvent.Enter, DisplayState.IDLE))
}
@Test
fun `Enter in IDLE on non-blank input resolves to SubmitInput`() {
assertEquals(Action.SubmitInput, resolve(KeyEvent.Enter, DisplayState.IDLE, inputText = "wf"))
}
// ── IN_SESSION display state ──
@Test
fun `NavUp in IN_SESSION resolves to NavigateUp`() {
assertEquals(Action.NavigateUp, resolve(KeyEvent.NavUp, DisplayState.IN_SESSION))
}
@Test
fun `NavDown in IN_SESSION resolves to NavigateDown`() {
assertEquals(Action.NavigateDown, resolve(KeyEvent.NavDown, DisplayState.IN_SESSION))
}
@Test
fun `CharInput in ROUTER mode resolves to AppendChar`() {
val result = KeyResolver.resolve(KeyEvent.CharInput('x'), InputMode.ROUTER, "")
assertEquals(Action.AppendChar('x'), result)
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))
}
}
@@ -10,26 +10,11 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
class TambouiKeyMapperTest {
// --- Ctrl-C always → Quit ---
// --- Ctrl-C always → Cancel ---
@Test
fun `ctrl-c maps to Quit in InputMode ROUTER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
}
@Test
fun `ctrl-c maps to Quit in InputMode FILTER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
}
@Test
fun `ctrl-c maps to Quit in InputMode NAVIGATE`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
}
@Test
fun `ctrl-c maps to Quit in InputMode STEER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
fun `ctrl-c maps to Cancel`() {
assertEquals(KeyEvent.Cancel, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
}
// --- Ctrl+key → keybind events ---
@@ -55,18 +40,13 @@ class TambouiKeyMapperTest {
}
@Test
fun `ctrl-s maps to Steer`() {
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code)))
fun `ctrl-h maps to ShowPendingApproval`() {
assertEquals(KeyEvent.ShowPendingApproval, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code)))
}
@Test
fun `alt-h maps to ToggleApprovalOverlay`() {
assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'h'.code)))
}
@Test
fun `ctrl-e maps to ToggleEventOverlay`() {
assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code)))
fun `ctrl-e maps to ToggleEventStrip`() {
assertEquals(KeyEvent.ToggleEventStrip, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code)))
}
@Test
@@ -109,12 +89,7 @@ class TambouiKeyMapperTest {
}
@Test
fun `bare q maps to CharInput in STEER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
}
@Test
fun `bare q maps to CharInput in NAVIGATE`() {
fun `bare q maps to CharInput`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
}
@@ -146,13 +121,8 @@ class TambouiKeyMapperTest {
}
@Test
fun `TAB maps to Filter`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
}
@Test
fun `TAB maps to Filter in FILTER mode`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
fun `TAB maps to Tab`() {
assertEquals(KeyEvent.Tab, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
}
// --- ISO control chars → null ---
@@ -1,12 +1,7 @@
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.ApprovalInfo
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.TuiState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@@ -14,30 +9,6 @@ import org.junit.jupiter.api.Test
class InputReducerTest {
private val activeApproval = ApprovalInfo(
requestId = "req-1",
sessionId = "sess-1",
tier = "HIGH",
riskSummary = "HIGH",
toolName = "bash",
preview = null,
)
private val sessionWithApproval = SessionSummary(
id = "sess-1",
status = "running",
workflowId = "wf",
lastEventAt = 0L,
pendingApproval = activeApproval,
)
private fun stateWithActiveApproval(inputMode: InputMode = InputMode.ROUTER, inputBuffer: String = "") =
TuiState(
inputMode = inputMode,
inputBuffer = inputBuffer,
sessions = SessionsState(sessions = listOf(sessionWithApproval), selectedId = "sess-1"),
)
private fun reduce(
state: TuiState = TuiState(),
action: Action,
@@ -51,23 +22,6 @@ class InputReducerTest {
assertTrue(effects.isEmpty())
}
@Test
fun `OpenSteeringPrompt sets mode to STEER when approval active`() {
val initial = stateWithActiveApproval()
val (state, effects) = reduce(state = initial, action = Action.OpenSteeringPrompt)
assertEquals(InputMode.STEER, state.inputMode)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `OpenSteeringPrompt is no-op when no active approval`() {
val initial = TuiState()
val (state, effects) = reduce(state = initial, action = Action.OpenSteeringPrompt)
assertEquals(initial, state)
assertTrue(effects.isEmpty())
}
@Test
fun `AppendChar appends character to buffer`() {
val (state, effects) = reduce(
@@ -109,64 +63,32 @@ class InputReducerTest {
}
@Test
fun `SubmitInput in ROUTER mode emits StartSession effect`() {
fun `SubmitInput clears input buffer with no effects`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow"),
action = Action.SubmitInput,
)
assertEquals(InputMode.ROUTER, state.inputMode)
assertEquals(1, effects.size)
val effect = effects[0] as Effect.SendWs
val msg = effect.message as ClientMessage.StartSession
assertEquals("my-workflow", msg.workflowId)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `SubmitInput in ROUTER mode with blank text emits no effect`() {
fun `SubmitInput with blank text clears input buffer`() {
val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = " "),
action = Action.SubmitInput,
)
assertEquals(InputMode.ROUTER, state.inputMode)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
@Test
fun `SubmitInput in STEER mode with active approval emits ApprovalResponse APPROVE with steering note`() {
val (state, effects) = reduce(
state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "steer this way"),
action = Action.SubmitInput,
)
assertEquals(InputMode.ROUTER, state.inputMode)
assertEquals(1, effects.size)
val effect = effects[0] as Effect.SendWs
val msg = effect.message as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.APPROVE, msg.decision)
assertEquals("steer this way", msg.steeringNote)
assertEquals("req-1", msg.requestId.value)
}
@Test
fun `SubmitInput in STEER mode clears pendingApproval atomically with the response effect`() {
val (state, effects) = reduce(
state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "go left"),
action = Action.SubmitInput,
)
assertEquals(null, state.sessions.sessions[0].pendingApproval)
assertEquals(1, effects.size)
val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.APPROVE, msg.decision)
assertEquals("go left", msg.steeringNote)
assertEquals("req-1", msg.requestId.value)
}
@Test
fun `SubmitInput in FILTER mode clears input with no effects`() {
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(InputMode.ROUTER, state.inputMode)
assertEquals("", state.inputBuffer)
assertTrue(effects.isEmpty())
}
}
@@ -5,10 +5,12 @@ import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.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.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
@@ -53,29 +55,29 @@ class RootReducerTest {
}
@Test
fun `NavigateUp with sessions adjusts selected id`() {
val state0 = TuiState(snapshotPhase = false)
val startMsg = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s1"),
workflowId = "wf",
sequence = 1L,
sessionSequence = 1L,
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 (state1, _) = RootReducer.reduce(state0, Action.ServerEventReceived(startMsg), fixedClock)
val startMsg2 = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s2"),
workflowId = "wf",
sequence = 2L,
sessionSequence = 1L,
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 (state2, _) = RootReducer.reduce(state1, Action.ServerEventReceived(startMsg2), fixedClock)
// selected is s1 (first); navigating down should move to s2
val (state3, _) = RootReducer.reduce(
state2.copy(inputMode = InputMode.NAVIGATE),
Action.NavigateDown,
fixedClock,
)
assertEquals("s2", state3.sessions.selectedId)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals("c", result.sessions.selectedId)
}
@Test
@@ -138,6 +140,151 @@ class RootReducerTest {
assertTrue(effects.isEmpty())
}
// ── Input history navigation (ARCH-HISTORY-2/3) ──
@Test
fun `NavigateUp in IN_SESSION at index -1 saves buffer and loads last history entry`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")),
inputHistoryIndex = -1,
inputBuffer = "current input",
savedInputBuffer = "",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals(2, result.inputHistoryIndex)
assertEquals("msg3", result.inputBuffer)
assertEquals("current input", result.savedInputBuffer)
}
@Test
fun `NavigateUp in IN_SESSION from index above 0 decrements index and loads entry`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")),
inputHistoryIndex = 2,
inputBuffer = "msg3",
savedInputBuffer = "original",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals(1, result.inputHistoryIndex)
assertEquals("msg2", result.inputBuffer)
assertEquals("original", result.savedInputBuffer)
}
@Test
fun `NavigateUp in IN_SESSION at index 0 stays at oldest entry`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2")),
inputHistoryIndex = 0,
inputBuffer = "msg1",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals(0, result.inputHistoryIndex)
assertEquals("msg1", result.inputBuffer)
}
@Test
fun `NavigateUp in IN_SESSION with empty history is no-op`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = emptyMap(),
inputHistoryIndex = -1,
inputBuffer = "still here",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
assertEquals(-1, result.inputHistoryIndex)
assertEquals("still here", result.inputBuffer)
}
@Test
fun `NavigateDown in IN_SESSION at index -1 stays at current buffer`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2")),
inputHistoryIndex = -1,
inputBuffer = "typing...",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock)
assertEquals(-1, result.inputHistoryIndex)
assertEquals("typing...", result.inputBuffer)
}
@Test
fun `NavigateDown in IN_SESSION at last entry restores savedInputBuffer and resets index`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2")),
inputHistoryIndex = 1,
inputBuffer = "msg2",
savedInputBuffer = "my saved text",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock)
assertEquals(-1, result.inputHistoryIndex)
assertEquals("my saved text", result.inputBuffer)
}
@Test
fun `NavigateDown in IN_SESSION from below last entry increments index`() {
val state = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2", "msg3")),
inputHistoryIndex = 0,
inputBuffer = "msg1",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateDown, fixedClock)
assertEquals(1, result.inputHistoryIndex)
assertEquals("msg2", result.inputBuffer)
}
@Test
fun `NavigateUp in IN_SESSION with FILTER mode does not navigate history`() {
val state = TuiState(
snapshotPhase = false,
inputMode = InputMode.FILTER,
sessions = SessionsState(
sessions = listOf(session("s1")),
selectedId = "s1",
),
inputHistory = mapOf("s1" to listOf("msg1", "msg2")),
inputHistoryIndex = -1,
inputBuffer = "dont touch",
)
val (result, _) = RootReducer.reduce(state, Action.NavigateUp, fixedClock)
// In FILTER mode, history nav should not activate
assertEquals(-1, result.inputHistoryIndex)
assertEquals("dont touch", result.inputBuffer)
}
@Test
fun `AppendChar followed by Backspace returns to original buffer`() {
val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab")
@@ -161,7 +308,7 @@ class RootReducerTest {
private fun session(id: String) = SessionSummary(id = id, status = "ACTIVE", workflowId = "wf", lastEventAt = 0L)
@Test
fun `ApprovalRequired on selected session auto-opens approvalOverlayVisible`() {
fun `ApprovalRequired on selected session transitions display state to APPROVAL`() {
val initial = TuiState(
snapshotPhase = false,
sessions = SessionsState(sessions = listOf(session("s1")), selectedId = "s1"),
@@ -169,12 +316,12 @@ class RootReducerTest {
val (state, _) = RootReducer.reduce(
initial, Action.ServerEventReceived(approvalRequiredMsg("s1")), fixedClock,
)
assertEquals(true, state.approvalOverlayVisible)
assertEquals(DisplayState.APPROVAL, state.displayState)
assertEquals("r1", state.sessions.sessions[0].pendingApproval?.requestId)
}
@Test
fun `ApprovalRequired on non-selected session does not open approvalOverlayVisible`() {
fun `ApprovalRequired on non-selected session does not transition to APPROVAL`() {
val initial = TuiState(
snapshotPhase = false,
sessions = SessionsState(
@@ -185,7 +332,7 @@ class RootReducerTest {
val (state, _) = RootReducer.reduce(
initial, Action.ServerEventReceived(approvalRequiredMsg("s2")), fixedClock,
)
assertEquals(false, state.approvalOverlayVisible)
assertEquals(DisplayState.IN_SESSION, state.displayState)
assertEquals(null, state.sessions.sessions.find { it.id == "s1" }?.pendingApproval)
assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId)
}
@@ -8,6 +8,7 @@ import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.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
@@ -35,15 +36,16 @@ class SessionsReducerTest {
private fun reduce(
sessions: SessionsState = SessionsState(),
displayState: DisplayState = DisplayState.IDLE,
inputMode: InputMode = InputMode.ROUTER,
inputText: String = "",
action: Action,
) = SessionsReducer.reduce(sessions, inputMode, inputText, action, fixedClock)
) = SessionsReducer.reduce(sessions, displayState, inputMode, inputText, action, 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, inputMode = InputMode.NAVIGATE, action = Action.NavigateUp)
val (state, effects) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateUp)
assertEquals("c", state.selectedId)
assertTrue(effects.isEmpty())
}
@@ -51,14 +53,14 @@ class SessionsReducerTest {
@Test
fun `NavigateDown advances selection`() {
val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "a")
val (state, _) = reduce(sessions = s, inputMode = InputMode.NAVIGATE, action = Action.NavigateDown)
val (state, _) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateDown)
assertEquals("b", state.selectedId)
}
@Test
fun `NavigateDown wraps from bottom to top`() {
val s = SessionsState(sessions = listOf(session("a"), session("b")), selectedId = "b")
val (state, _) = reduce(sessions = s, inputMode = InputMode.NAVIGATE, action = Action.NavigateDown)
val (state, _) = reduce(sessions = s, displayState = DisplayState.IDLE, action = Action.NavigateDown)
assertEquals("a", state.selectedId)
}
@@ -318,32 +320,25 @@ class SessionsReducerTest {
)
@Test
fun `ApproveActive emits ApprovalResponse APPROVE and clears pendingApproval`() {
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(null, state.sessions[0].pendingApproval)
assertEquals(1, effects.size)
val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.APPROVE, msg.decision)
assertEquals("req-1", msg.requestId.value)
assertEquals(null, msg.steeringNote)
assertEquals(s, state)
assertTrue(effects.isEmpty())
}
@Test
fun `RejectActive emits ApprovalResponse REJECT and clears pendingApproval`() {
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(null, state.sessions[0].pendingApproval)
assertEquals(1, effects.size)
val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.REJECT, msg.decision)
assertEquals("req-1", msg.requestId.value)
assertEquals(s, state)
assertTrue(effects.isEmpty())
}
@Test