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:
2026-05-26 14:44:27 +04:00
parent d701e3cbf3
commit 2165d1b899
26 changed files with 650 additions and 81 deletions
@@ -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,
)
}