feat(tui): session display model with DisplayState, input history nav, and StageToolManifest
Restructure the TUI around a DisplayState enum (IDLE, IN_SESSION, APPROVAL) replacing the previous InputMode-based navigation (NAVIGATE, STEER removed). Key changes: - **DisplayState model**: New `DisplayState` enum governs per-mode key resolution and UI rendering. Removes `ROUTER`/`NAVIGATE`/`STEER`/`FILTER` InputMode complexity. - **Input history navigation**: Up/Down arrows in IN_SESSION cycle through per-session input history (ARCH-HISTORY-2/3). `savedInputBuffer` preserves in-flight text. - **Input history filter**: `Tab` toggles between ROUTER and FILTER modes; history nav disabled while filtering. - **Ctrl-C → Cancel**: Rebind Ctrl-C from Quit to Cancel across all states. - **Approval flow**: `ApproveActive`/`RejectActive` moved from SessionsReducer to RootReducer via `pendingDecision`/`SubmitApprovalDecision`. Approval overlay replaced by `ApprovalSurface` component. - **Event history strip**: Replaces old overlay with a persistent `EventHistoryStrip` component, toggled via Ctrl-E. - **Background update badge**: New `backgroundUpdateCount` on SessionsState tracks events arriving for non-selected sessions (ARCH-BADGE-1). - **StageToolManifest**: New `ServerMessage.StageToolManifest` and `ToolManifestEntry` model for pre-declared tool shapes per stage (RF-3). - **Cleanup**: Removed `ActiveSession`, `ApprovalPanel`, `ToolsPanel` components. Removed `InputMode.NAVIGATE`, `InputMode.STEER`, approval overlay state fields. Various reducer simplifications (no more overlaid effects for approval responses).
This commit is contained in:
@@ -131,6 +131,7 @@ fun main() {
|
||||
routerFacade = routerFacade,
|
||||
orchestrationRepository = repositories.orchestrationRepository,
|
||||
approvalRepository = repositories.approvalRepository,
|
||||
toolRegistry = toolRegistry,
|
||||
approvalConfig = correxConfig.approval,
|
||||
)
|
||||
module.start()
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -32,6 +33,7 @@ class ServerModule(
|
||||
val routerFacade: RouterFacade,
|
||||
val orchestrationRepository: OrchestrationRepository,
|
||||
val approvalRepository: DefaultApprovalRepository,
|
||||
val toolRegistry: ToolRegistry,
|
||||
approvalConfig: ApprovalConfig = ApprovalConfig(),
|
||||
// Long-lived scope owned by the module — backs the ApprovalCoordinator timers
|
||||
// and event-store subscription. SupervisorJob so one failure doesn't kill the
|
||||
|
||||
@@ -3,18 +3,24 @@ package com.correx.apps.server.bridge
|
||||
import com.correx.apps.server.protocol.ApprovalDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.SessionStateDto
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
|
||||
class SessionEventBridge(
|
||||
private val eventStore: EventStore,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val orchestrationRepository: OrchestrationRepository,
|
||||
private val approvalRepository: DefaultApprovalRepository,
|
||||
private val workflowRegistry: WorkflowRegistry,
|
||||
private val toolRegistry: ToolRegistry,
|
||||
private val send: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
suspend fun replaySnapshot() {
|
||||
@@ -46,6 +52,24 @@ class SessionEventBridge(
|
||||
lastSequence = lastGlobal,
|
||||
lastSessionSequence = lastSession,
|
||||
))
|
||||
|
||||
// Emit StageToolManifest for this session (ARCH-TOOL-1)
|
||||
val graph = workflowRegistry.find(orchState.workflowId)
|
||||
if (graph != null) {
|
||||
val stages = graph.stages.map { (stageId, stageConfig) ->
|
||||
val tools = stageConfig.allowedTools.mapNotNull { toolName ->
|
||||
toolRegistry.resolve(toolName)?.let { tool ->
|
||||
ToolDecl(name = tool.name, tier = tool.tier.level)
|
||||
}
|
||||
}
|
||||
StageToolDecl(stageId = stageId.value, tools = tools)
|
||||
}
|
||||
send(ServerMessage.StageToolManifest(
|
||||
sessionId = sessionId,
|
||||
workflowId = orchState.workflowId,
|
||||
stages = stages,
|
||||
))
|
||||
}
|
||||
}
|
||||
send(ServerMessage.SnapshotComplete)
|
||||
}
|
||||
|
||||
@@ -40,3 +40,15 @@ enum class PauseReason {
|
||||
APPROVAL_PENDING,
|
||||
USER_REQUESTED,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StageToolDecl(
|
||||
val stageId: String,
|
||||
val tools: List<ToolDecl>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ToolDecl(
|
||||
val name: String,
|
||||
val tier: Int,
|
||||
)
|
||||
|
||||
@@ -211,6 +211,16 @@ sealed interface ServerMessage {
|
||||
|
||||
// -- System / infra --
|
||||
|
||||
@Serializable
|
||||
@SerialName("stage.tool_manifest")
|
||||
data class StageToolManifest(
|
||||
val sessionId: SessionId,
|
||||
val workflowId: String,
|
||||
val stages: List<StageToolDecl>,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
@Serializable
|
||||
@SerialName("provider.status_changed")
|
||||
data class ProviderStatusChanged(
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||
import com.correx.apps.server.protocol.ProviderHealthDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
@@ -47,9 +49,11 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
val bridge = SessionEventBridge(
|
||||
eventStore = module.eventStore,
|
||||
artifactStore = module.artifactStore,
|
||||
send = sendFrame,
|
||||
orchestrationRepository = module.orchestrationRepository,
|
||||
approvalRepository = module.approvalRepository,
|
||||
workflowRegistry = module.workflowRegistry,
|
||||
toolRegistry = module.toolRegistry,
|
||||
send = sendFrame,
|
||||
)
|
||||
val mapper = DomainEventMapper(module.artifactStore)
|
||||
|
||||
@@ -201,6 +205,23 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
sessionSequence = 0L,
|
||||
)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
|
||||
|
||||
// Emit StageToolManifest for the newly created session (ARCH-TOOL-1)
|
||||
val stages = graph.stages.map { (stageId, stageConfig) ->
|
||||
val tools = stageConfig.allowedTools.mapNotNull { toolName ->
|
||||
module.toolRegistry.resolve(toolName)?.let { tool ->
|
||||
ToolDecl(name = tool.name, tier = tool.tier.level)
|
||||
}
|
||||
}
|
||||
StageToolDecl(stageId = stageId.value, tools = tools)
|
||||
}
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(
|
||||
ServerMessage.StageToolManifest(
|
||||
sessionId = sessionId,
|
||||
workflowId = msg.workflowId,
|
||||
stages = stages,
|
||||
),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-4
@@ -1,6 +1,7 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
@@ -21,6 +22,8 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -98,6 +101,16 @@ class SessionEventBridgeTest {
|
||||
),
|
||||
)
|
||||
|
||||
private val noopWorkflowRegistry = object : WorkflowRegistry {
|
||||
override fun listAll(): List<com.correx.apps.server.registry.WorkflowSummary> = emptyList()
|
||||
override fun find(workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? = null
|
||||
}
|
||||
|
||||
private val noopToolRegistry = object : ToolRegistry {
|
||||
override fun resolve(name: String): Tool? = null
|
||||
override fun all(): List<Tool> = emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaySnapshot sends mapped messages for all events`() = runTest {
|
||||
val events = listOf(
|
||||
@@ -112,6 +125,8 @@ class SessionEventBridgeTest {
|
||||
noopArtifactStore,
|
||||
orchRepo,
|
||||
approvalRepository,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
@@ -138,6 +153,8 @@ class SessionEventBridgeTest {
|
||||
noopArtifactStore,
|
||||
orchRepo,
|
||||
approvalRepository,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
@@ -162,6 +179,8 @@ class SessionEventBridgeTest {
|
||||
noopArtifactStore,
|
||||
activeOrchestrationRepository(),
|
||||
approvalRepository,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.streamLive(sessionId)
|
||||
@@ -181,7 +200,7 @@ class SessionEventBridgeTest {
|
||||
fun `replaySnapshot with no sessions emits only SnapshotComplete`() = runTest {
|
||||
val store = fakeEventStore(allEventsList = emptyList())
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
|
||||
bridge.replaySnapshot()
|
||||
assertEquals(1, sent.size)
|
||||
assertEquals(ServerMessage.SnapshotComplete, sent[0])
|
||||
@@ -192,7 +211,7 @@ class SessionEventBridgeTest {
|
||||
val events = listOf(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
|
||||
bridge.replaySnapshot()
|
||||
assertEquals(ServerMessage.SnapshotComplete, sent.last())
|
||||
}
|
||||
@@ -207,7 +226,7 @@ class SessionEventBridgeTest {
|
||||
override suspend fun lastGlobalSequence(): Long = 5L
|
||||
}
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
|
||||
bridge.replaySnapshot()
|
||||
val snapshot = sent[0] as ServerMessage.SessionSnapshot
|
||||
assertEquals(5L, snapshot.lastSequence)
|
||||
@@ -221,7 +240,7 @@ class SessionEventBridgeTest {
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, activeOrchestrationRepository(), approvalRepository, noopWorkflowRegistry, noopToolRegistry) { sent.add(it) }
|
||||
bridge.replaySnapshot()
|
||||
val snapshot = sent[0] as ServerMessage.SessionSnapshot
|
||||
assertEquals(3L, snapshot.lastSessionSequence)
|
||||
@@ -240,6 +259,8 @@ class SessionEventBridgeTest {
|
||||
noopArtifactStore,
|
||||
activeOrchestrationRepository(),
|
||||
approvalRepository,
|
||||
noopWorkflowRegistry,
|
||||
noopToolRegistry,
|
||||
) { sent.add(it) }
|
||||
|
||||
channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.correx.apps.server.ws
|
||||
import com.correx.apps.server.bridge.DomainEventMapper
|
||||
import com.correx.apps.server.bridge.SessionEventBridge
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
@@ -21,6 +22,8 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
@@ -103,6 +106,16 @@ class GlobalStreamHandlerTest {
|
||||
},
|
||||
)
|
||||
|
||||
private val noopWorkflowRegistry = object : WorkflowRegistry {
|
||||
override fun listAll(): List<com.correx.apps.server.registry.WorkflowSummary> = emptyList()
|
||||
override fun find(workflowId: String): com.correx.core.transitions.graph.WorkflowGraph? = null
|
||||
}
|
||||
|
||||
private val noopToolRegistry = object : ToolRegistry {
|
||||
override fun resolve(name: String): Tool? = null
|
||||
override fun all(): List<Tool> = emptyList()
|
||||
}
|
||||
|
||||
private fun fakeApprovalRepository(store: EventStore): DefaultApprovalRepository =
|
||||
DefaultApprovalRepository(
|
||||
DefaultEventReplayer(
|
||||
@@ -121,7 +134,7 @@ class GlobalStreamHandlerTest {
|
||||
): Pair<SessionEventBridge, Channel<ServerMessage>> {
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, approvals) { received.send(it) }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, orchRepo, approvals, noopWorkflowRegistry, noopToolRegistry) { received.send(it) }
|
||||
return bridge to received
|
||||
}
|
||||
|
||||
@@ -168,7 +181,7 @@ class GlobalStreamHandlerTest {
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 5L)
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) {
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) {
|
||||
received.send(it)
|
||||
}
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
@@ -211,7 +224,7 @@ class GlobalStreamHandlerTest {
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val received = Channel<ServerMessage>(Channel.UNLIMITED)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) {
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) {
|
||||
received.send(it)
|
||||
}
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
@@ -260,7 +273,7 @@ class GlobalStreamHandlerTest {
|
||||
}
|
||||
}
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { }
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
|
||||
val job = launch {
|
||||
@@ -278,7 +291,7 @@ class GlobalStreamHandlerTest {
|
||||
val liveFlow = MutableSharedFlow<StoredEvent>()
|
||||
val store = fakeEventStore(liveFlow, emptySet(), lastGlobal = 0L)
|
||||
val approvals = fakeApprovalRepository(store)
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals) { }
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore, idleOrchestrationRepository(), approvals, noopWorkflowRegistry, noopToolRegistry) { }
|
||||
val mapper = DomainEventMapper(noopArtifactStore)
|
||||
|
||||
val job = launch {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user