feat(tui): diff viewer, workflow picker, cursor support, and approval reconnect fix
- Add unified diff viewer for file_write/file_edit tools across all layers: ToolReceipt.diff → DiffUtil (LCS-based) → SandboxedToolExecutor → ToolCompleted → TuiToolRecord.diff → DiffViewer widget (colored +/- lines) - Add workflow selection panel: WorkflowList server message, WorkflowListPanel widget, ↑↓ seamlessly extends into workflow picker from session list - Add cursor/caret support: inputCursor in TuiState, CursorLeft/CursorRight actions and key events, AppendChar inserts at cursor, Backspace deletes before - Colorize event history strip (green/red/yellow/blue by type) and input bar (blue/yellow prompt, cyan session name, blue keybindings) - Show 5 recent events instead of 3; increase event strip height to 6 - Remove lastEventAt sort so display and navigation use consistent order - Fix approval reconnect: register pending approvals with ApprovalCoordinator during snapshot replay so lookupSession works after reconnect - 167 tests pass (126 TUI + 41 server)
This commit is contained in:
@@ -76,6 +76,15 @@ open class ApprovalCoordinator(
|
||||
|
||||
fun lookupSession(requestId: ApprovalRequestId): SessionId? = requestSessions[requestId]
|
||||
|
||||
/**
|
||||
* Register a pending approval request that was reconstructed from the persisted event store
|
||||
* during snapshot replay. This makes [lookupSession] work for approvals that were created
|
||||
* before the current connection's live stream started.
|
||||
*/
|
||||
fun registerPendingRequest(requestId: ApprovalRequestId, sessionId: SessionId) {
|
||||
requestSessions[requestId] = sessionId
|
||||
}
|
||||
|
||||
open fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
|
||||
if (resolved.putIfAbsent(msg.requestId, true) != null) {
|
||||
return ServerMessage.ProtocolError(
|
||||
|
||||
@@ -119,6 +119,7 @@ suspend fun domainEventToServerMessage(
|
||||
toolName = p.toolName,
|
||||
outputSummary = p.receipt.outputSummary,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
diff = p.receipt.diff,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.approval.ApprovalCoordinator
|
||||
import com.correx.apps.server.protocol.ApprovalDto
|
||||
import com.correx.apps.server.protocol.EventEntryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
@@ -25,6 +26,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
@@ -36,6 +38,7 @@ class SessionEventBridge(
|
||||
private val approvalRepository: DefaultApprovalRepository,
|
||||
private val workflowRegistry: WorkflowRegistry,
|
||||
private val toolRegistry: ToolRegistry,
|
||||
private val approvalCoordinator: ApprovalCoordinator? = null,
|
||||
private val send: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
suspend fun replaySnapshot() {
|
||||
@@ -67,6 +70,12 @@ class SessionEventBridge(
|
||||
.mapNotNull { eventToEntry(it) }
|
||||
.takeLast(7)
|
||||
|
||||
// Re-register pending approvals so the ApprovalCoordinator can route responses
|
||||
// from clients that connected after the ApprovalRequestedEvent was emitted.
|
||||
pendingApprovals.forEach { dto ->
|
||||
approvalCoordinator?.registerPendingRequest(ApprovalRequestId(dto.requestId), sessionId)
|
||||
}
|
||||
|
||||
send(ServerMessage.SessionSnapshot(
|
||||
sessionId = sessionId,
|
||||
workflowId = orchState.workflowId,
|
||||
|
||||
@@ -61,3 +61,9 @@ data class EventEntryDto(
|
||||
val type: String,
|
||||
val detail: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorkflowDto(
|
||||
val workflowId: String,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
@@ -171,6 +171,7 @@ sealed interface ServerMessage {
|
||||
val toolName: String,
|
||||
val outputSummary: String,
|
||||
val occurredAt: Long,
|
||||
val diff: String? = null,
|
||||
override val sequence: Long,
|
||||
override val sessionSequence: Long,
|
||||
) : ServerMessage, SessionMessage
|
||||
@@ -261,4 +262,14 @@ sealed interface ServerMessage {
|
||||
override val sequence: Long? get() = null
|
||||
override val sessionSequence: Long? get() = null
|
||||
}
|
||||
|
||||
// -- Workflow discovery --
|
||||
|
||||
@Serializable
|
||||
@SerialName("workflow.list")
|
||||
data class WorkflowList(
|
||||
val workflows: List<WorkflowDto>,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.WorkflowDto
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
@@ -54,6 +55,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
workflowRegistry = module.workflowRegistry,
|
||||
toolRegistry = module.toolRegistry,
|
||||
send = sendFrame,
|
||||
approvalCoordinator = module.approvalCoordinator,
|
||||
)
|
||||
val mapper = DomainEventMapper(module.artifactStore)
|
||||
|
||||
@@ -117,6 +119,15 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg)))
|
||||
}
|
||||
|
||||
// Send available workflows so the TUI can show a workflow picker
|
||||
val workflows = module.workflowRegistry.listAll().map { summary ->
|
||||
WorkflowDto(workflowId = summary.workflowId, description = summary.description)
|
||||
}
|
||||
log.debug("workflow list: {} workflow(s)", workflows.size)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(
|
||||
ServerMessage.WorkflowList(workflows = workflows),
|
||||
)))
|
||||
}
|
||||
|
||||
private suspend fun handleClientMessage(
|
||||
|
||||
@@ -307,7 +307,7 @@ class DomainEventMapperTest {
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
|
||||
assertEquals(
|
||||
ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines", occurredAt, event.sequence, 0L),
|
||||
ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines", occurredAt, diff = null, event.sequence, 0L),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user