diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt b/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt index 42029564..3eec7fa6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt @@ -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( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt index b8ab4960..4f113b79 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt @@ -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, ) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt index 8f30b5c3..7678400b 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt @@ -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, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt index 86c59a90..1bf76b6e 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/Dtos.kt @@ -61,3 +61,9 @@ data class EventEntryDto( val type: String, val detail: String, ) + +@Serializable +data class WorkflowDto( + val workflowId: String, + val description: String, +) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index aa21062f..585fd2c5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -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, + override val sequence: Long? = null, + override val sessionSequence: Long? = null, + ) : ServerMessage, NonEventMessage } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 3d6f3284..23dd1b39 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -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( diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt index 13d1945c..2174ec02 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/bridge/DomainEventMapperTest.kt @@ -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, ) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt index 8e856c3b..27eee423 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt @@ -17,4 +17,6 @@ sealed class KeyEvent { object ReturnToSessionList : KeyEvent() object ShowPendingApproval : KeyEvent() data class CharInput(val ch: Char) : KeyEvent() + object CursorLeft : KeyEvent() + object CursorRight : KeyEvent() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt index 1338c686..80c8c8c3 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt @@ -1,11 +1,13 @@ package com.correx.apps.tui import com.correx.apps.tui.components.approvalSurfaceWidget +import com.correx.apps.tui.components.diffViewerWidget 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.workflowListWidget import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.KeyResolver import com.correx.apps.tui.input.mapKey @@ -14,6 +16,7 @@ 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.ToolDisplayStatus import com.correx.apps.tui.state.displayState import com.correx.apps.tui.state.selectedPendingApproval import com.correx.apps.tui.ws.ConnectionEvent @@ -34,7 +37,9 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent private const val DEFAULT_PORT = 8080 private const val STATUS_HEIGHT = 1 -private const val EVENT_STRIP_HEIGHT = 4 +private const val EVENT_STRIP_HEIGHT = 6 +private const val DIFF_HEIGHT = 8 +private const val WORKFLOW_HEIGHT = 6 private const val INPUT_HEIGHT = 4 private val log = LoggerFactory.getLogger("com.correx.apps.tui.main") @@ -125,27 +130,52 @@ private fun render(frame: Frame, state: TuiState) { private fun renderIdleLayout(frame: Frame, state: TuiState) { val area = frame.area() - val vertRects = Layout.vertical() - .constraints( - Constraint.length(STATUS_HEIGHT), - Constraint.fill(), - Constraint.length(INPUT_HEIGHT), - ) - .split(area) + val hasWorkflows = state.sessions.workflows.isNotEmpty() + val vertRects = if (hasWorkflows) { + Layout.vertical() + .constraints( + Constraint.length(STATUS_HEIGHT), + Constraint.fill(), + Constraint.length(WORKFLOW_HEIGHT), + Constraint.length(INPUT_HEIGHT), + ) + .split(area) + } else { + Layout.vertical() + .constraints( + Constraint.length(STATUS_HEIGHT), + Constraint.fill(), + Constraint.length(INPUT_HEIGHT), + ) + .split(area) + } frame.renderWidget(statusBarWidget(state), vertRects[0]) frame.renderWidget(sessionListWidget(state), vertRects[1]) - frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[2]) + if (hasWorkflows) { + frame.renderWidget(workflowListWidget(state), vertRects[2]) + frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[3]) + } else { + frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[2]) + } } private fun renderInSessionLayout(frame: Frame, state: TuiState) { val area = frame.area() + val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } + val hasDiff = selectedSession?.tools?.any { + it.status == ToolDisplayStatus.COMPLETED && it.diff != null + } == true + val constraints = mutableListOf( Constraint.length(STATUS_HEIGHT), ) if (state.eventStripVisible) { constraints.add(Constraint.length(EVENT_STRIP_HEIGHT)) } + if (hasDiff) { + constraints.add(Constraint.length(DIFF_HEIGHT)) + } constraints.add(Constraint.fill()) constraints.add(Constraint.length(INPUT_HEIGHT)) @@ -158,6 +188,9 @@ private fun renderInSessionLayout(frame: Frame, state: TuiState) { if (state.eventStripVisible) { frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++]) } + if (hasDiff) { + frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++]) + } frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++]) frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx]) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/DiffViewer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/DiffViewer.kt new file mode 100644 index 00000000..400aee44 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/DiffViewer.kt @@ -0,0 +1,79 @@ +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.block.Title +import dev.tamboui.widgets.paragraph.Paragraph + +private const val DIFF_MAX_LINES = 8 +private const val LINE_MAX_LENGTH = 100 + +private val dimStyle = Style.create().dim().gray() +private val greenStyle = Style.create().green() +private val redStyle = Style.create().red() +private val yellowStyle = Style.create().yellow() + +fun diffViewerWidget(state: TuiState): Paragraph { + val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } + + val toolWithDiff = selectedSession?.tools + ?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED && it.diff != null } + + val diffText = toolWithDiff?.diff ?: return Paragraph.builder().build() + + val header = " ${toolWithDiff.name}" + val headerLine = Line.from(Span.styled(header, Style.create().cyan())) + + val diffLines = diffText.lines() + val visible = diffLines.take(DIFF_MAX_LINES) + val overflow = diffLines.size - DIFF_MAX_LINES + + val contentLines = buildList { + for (line in visible) { + val truncated = if (line.length > LINE_MAX_LENGTH) { + line.take(LINE_MAX_LENGTH - 3) + "..." + } else { + line + } + when { + truncated.startsWith("@@") -> add( + Line.from(Span.styled(truncated, yellowStyle)), + ) + + truncated.startsWith("+++") || truncated.startsWith("---") -> add( + Line.from(Span.styled(truncated, dimStyle)), + ) + + truncated.startsWith("+") -> add( + Line.from(Span.styled(truncated, greenStyle)), + ) + + truncated.startsWith("-") -> add( + Line.from(Span.styled(truncated, redStyle)), + ) + + else -> add(Line.from(Span.raw(truncated))) + } + } + if (overflow > 0) { + add(Line.from(Span.styled(" ($overflow more lines...)", dimStyle))) + } + } + + val lines = listOf(headerLine) + contentLines + + val block = Block.builder() + .title(Title.from(Span.styled("diff", Style.create().green())).centered()) + .borders(Borders.ALL) + .borderType(BorderType.ROUNDED) + .build() + + return Paragraph.builder().text(Text.from(lines)).block(block).build() +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt index f6ad7ed4..414ca3e4 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt @@ -35,20 +35,30 @@ fun eventHistoryStripWidget(state: TuiState): Paragraph { add(Line.from(Span.styled(stageInfo, dimStyle))) // Recent events (plain text, compact format) — capped to fit allocated height. - // Line 0 = stage info, lines 1-3 = events (max 3). - val events = selectedSession.recentEvents.takeLast(3) + // Line 0 = stage info, lines 1-5 = events (max 5). + val events = selectedSession.recentEvents.takeLast(5) if (events.isEmpty()) { add(Line.from(Span.styled("no events", dimStyle))) } else { for (ev in events) { val typePadded = ev.type.padEnd(TYPE_WIDTH, ' ') + val typeStyle = when (ev.type) { + "StageCompleted", "InferenceCompleted", "ToolCompleted", "SessionCompleted" -> Style.create().green() + "StageFailed", "SessionFailed", "ToolFailed", "ToolRejected" -> Style.create().red() + "SessionPaused", "InferenceTimeout" -> Style.create().yellow() + "StageStarted", "InferenceStarted", "ToolStarted" -> Style.create().blue() + else -> Style.create() + } 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))) + add(Line.from( + Span.styled("[${ev.timestamp}] ", dimStyle), + Span.styled("[$typePadded]", typeStyle), + Span.raw(" $detail"), + )) } } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt index e2ef38b2..f909b96a 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt @@ -13,6 +13,23 @@ import dev.tamboui.widgets.block.BorderType import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.paragraph.Paragraph +private fun promptStyle(inputMode: InputMode): Style = when (inputMode) { + InputMode.ROUTER -> Style.create().blue() + InputMode.FILTER -> Style.create().yellow() +} + +private fun cursorLine(buffer: String, cursor: Int, inputMode: InputMode): Line { + val prompt = Span.styled("▌", promptStyle(inputMode)) + if (buffer.isEmpty()) return Line.from(prompt, Span.raw("")) + val before = buffer.take(cursor) + val after = buffer.drop(cursor) + return if (cursor >= buffer.length) { + Line.from(prompt, Span.raw(" $before"), Span.styled(" ", Style.create().reversed())) + } else { + Line.from(prompt, Span.raw(" $before"), Span.styled(buffer[cursor].toString(), Style.create().reversed()), Span.raw(after.drop(1))) + } +} + fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph { val dimStyle = Style.create().dim().gray() @@ -53,15 +70,23 @@ private fun createRowsForIdleRouter( dimStyle: Style?, sessionName: String, ): Pair { - val cursor = if (state.inputBuffer.isNotEmpty()) { - Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer)) + val row1 = if (state.inputBuffer.isNotEmpty()) { + cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER) } else { - Line.from(Span.raw("▌ "), Span.styled("Session name…", dimStyle)) + Line.from(Span.styled("▌", promptStyle(InputMode.ROUTER)), Span.styled(" Session name…", dimStyle)) } - return cursor to Line.from(Span.styled( - "$sessionName · router tab filter ctrl+n new ctrl+e events ctrl+q", - dimStyle, - )) + return row1 to Line.from( + Span.styled(sessionName, Style.create().cyan()), + Span.styled(" · router ", dimStyle), + Span.styled("tab", Style.create().blue()), + Span.styled(" filter ", dimStyle), + Span.styled("ctrl+n", Style.create().blue()), + Span.styled(" new ", dimStyle), + Span.styled("ctrl+e", Style.create().blue()), + Span.styled(" events ", dimStyle), + Span.styled("ctrl+q", Style.create().blue()), + Span.styled(" quit", dimStyle), + ) } private fun createRowsForInSessionRouter( @@ -69,47 +94,73 @@ private fun createRowsForInSessionRouter( dimStyle: Style?, sessionName: String, ): Pair { - val cursor = if (state.inputBuffer.isNotEmpty()) { - Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer)) + val row1 = if (state.inputBuffer.isNotEmpty()) { + cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER) } else { - Line.from(Span.raw("▌ "), Span.styled("Ask anything…", dimStyle)) + Line.from(Span.styled("▌", promptStyle(InputMode.ROUTER)), 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, - )) + return row1 to Line.from( + Span.styled(sessionName, Style.create().cyan()), + Span.styled(" · router ", dimStyle), + Span.styled("tab", Style.create().blue()), + Span.styled(" filter ", dimStyle), + Span.styled("↑↓", Style.create().blue()), + Span.styled(" history ", dimStyle), + Span.styled("ctrl+e", Style.create().blue()), + Span.styled(" events ", dimStyle), + Span.styled("ctrl+l", Style.create().blue()), + Span.styled(" back ", dimStyle), + Span.styled("ctrl+q", Style.create().blue()), + Span.styled(" quit", dimStyle), + ) } private fun createRowsForApprovalRouter( state: TuiState, dimStyle: Style?, ): Pair { - val cursor = if (state.inputBuffer.isNotEmpty()) { - Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer)) + val row1 = if (state.inputBuffer.isNotEmpty()) { + cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER) } else { - Line.from(Span.raw("▌ "), Span.styled("Steering note (optional)…", dimStyle)) + Line.from(Span.styled("▌", promptStyle(InputMode.ROUTER)), Span.styled(" Steering note (optional)…", dimStyle)) } - val decisionIndicator = when (state.pendingDecision) { - null -> "" - ApprovalDecision.APPROVE -> " [decision: APPROVE]" - ApprovalDecision.REJECT -> " [decision: REJECT]" + val decisionSpans = when (state.pendingDecision) { + null -> listOf() + ApprovalDecision.APPROVE -> listOf(Span.styled(" [decision: ", dimStyle), Span.styled("APPROVE", Style.create().green()), Span.styled("]", dimStyle)) + ApprovalDecision.REJECT -> listOf(Span.styled(" [decision: ", dimStyle), Span.styled("REJECT", Style.create().red()), Span.styled("]", dimStyle)) } - return cursor to Line.from(Span.styled( - "approval · ctrl+a approve ctrl+r reject ↵ enter submit esc dismiss$decisionIndicator", - dimStyle, - )) + return row1 to Line.from( + Span.styled("approval", Style.create().yellow()), + Span.styled(" · ", dimStyle), + Span.styled("ctrl+a", Style.create().blue()), + Span.styled(" approve ", dimStyle), + Span.styled("ctrl+r", Style.create().blue()), + Span.styled(" reject ", dimStyle), + Span.styled("↵", Style.create().blue()), + Span.styled(" enter submit ", dimStyle), + Span.styled("esc", Style.create().blue()), + Span.styled(" dismiss", dimStyle), + *decisionSpans.toTypedArray(), + ) } private fun createRowsForFilter( state: TuiState, dimStyle: Style?, ): Pair { - val cursor = if (state.inputBuffer.isNotEmpty()) { - Line.from(Span.raw("▌ "), Span.raw(state.inputBuffer)) + val row1 = if (state.inputBuffer.isNotEmpty()) { + cursorLine(state.inputBuffer, state.inputCursor, InputMode.FILTER) } else { - Line.from(Span.raw("▌ "), Span.styled("/filter…", dimStyle)) + Line.from(Span.styled("▌", promptStyle(InputMode.FILTER)), Span.styled(" /filter…", dimStyle)) } - return cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle)) + return row1 to Line.from( + Span.styled("filtering", Style.create().yellow()), + Span.styled(" ", dimStyle), + Span.styled("esc", Style.create().blue()), + Span.styled(" clear ", dimStyle), + Span.styled("↵", Style.create().blue()), + Span.styled(" enter apply", dimStyle), + ) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt index 1edc2780..ab57e9e6 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt @@ -60,7 +60,6 @@ private fun statusSpan(s: SessionSummary): Span { fun filteredSessions(sessions: SessionsState): List { val f = sessions.filter - val sorted = sessions.sessions.sortedByDescending { it.lastEventAt } - return if (f.isEmpty()) sorted - else sorted.filter { it.workflowId.contains(f, ignoreCase = true) } + return if (f.isEmpty()) sessions.sessions + else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt new file mode 100644 index 00000000..cafd3308 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt @@ -0,0 +1,50 @@ +package com.correx.apps.tui.components + +import com.correx.apps.server.protocol.WorkflowDto +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 MAX_VISIBLE = 8 + +fun workflowListWidget(state: TuiState): Paragraph { + val dimStyle = Style.create().dim().gray() + val workflows = state.sessions.workflows + val visible = workflows.take(MAX_VISIBLE) + val overflow = workflows.size - MAX_VISIBLE + + val lines = buildList { + if (workflows.isEmpty()) { + add(Line.from(Span.styled("(no workflows configured)", dimStyle))) + } else { + for ((i, wf) in visible.withIndex()) { + val isSelected = i == state.sessions.selectedWorkflowIndex + val prefix = if (isSelected) Span.styled("▶ ", Style.create().blue()) else Span.raw(" ") + val name = Span.styled(wf.workflowId, Style.create().cyan()) + val desc = if (wf.description.isNotBlank() && wf.description != wf.workflowId) { + Span.styled(" · ${wf.description}", dimStyle) + } else { + Span.styled("", dimStyle) + } + add(Line.from(prefix, name, desc)) + } + if (overflow > 0) { + add(Line.from(Span.styled("— $overflow more —", dimStyle))) + } + } + } + + val block = Block.builder() + .title("workflows") + .borders(Borders.ALL) + .borderType(BorderType.ROUNDED) + .build() + + return Paragraph.builder().text(Text.from(lines)).block(block).build() +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt index 5024f06b..aa11f8ed 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt @@ -24,5 +24,7 @@ sealed interface Action { data object Disconnected : Action data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : Action data object CycleMode : Action + data object CursorLeft : Action + data object CursorRight : Action data object NoOp : Action } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt index 7f0c4082..912f1aff 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt @@ -21,6 +21,10 @@ object KeyResolver { } if (global != null) return global + // Cursor keys work everywhere text input is active + if (key is KeyEvent.CursorLeft) return Action.CursorLeft + if (key is KeyEvent.CursorRight) return Action.CursorRight + // FILTER mode override: ↑↓ always navigates sessions list if (inputMode == InputMode.FILTER) { return when (key) { diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt index c2e9f3df..47b1fdef 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt @@ -29,6 +29,8 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? { return when (event.code()) { KeyCode.UP -> KeyEvent.NavUp KeyCode.DOWN -> KeyEvent.NavDown + KeyCode.LEFT -> KeyEvent.CursorLeft + KeyCode.RIGHT -> KeyEvent.CursorRight KeyCode.ENTER -> KeyEvent.Enter KeyCode.ESCAPE -> KeyEvent.Escape KeyCode.BACKSPACE -> KeyEvent.Backspace diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt index 25e1fba1..d6734e81 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/InputReducer.kt @@ -21,26 +21,51 @@ object InputReducer { }, ) to emptyList() + is Action.CursorLeft -> state.copy( + inputCursor = (state.inputCursor - 1).coerceAtLeast(0), + ) to emptyList() + + is Action.CursorRight -> state.copy( + inputCursor = (state.inputCursor + 1).coerceAtMost(state.inputBuffer.length), + ) to emptyList() + is Action.OpenNewSessionPrompt -> state.copy( inputMode = InputMode.ROUTER, inputBuffer = "", + inputCursor = 0, ) to emptyList() - is Action.AppendChar -> state.copy( - inputBuffer = state.inputBuffer + action.ch, - ) to emptyList() + is Action.AppendChar -> { + val buf = state.inputBuffer + val cur = state.inputCursor + state.copy( + inputBuffer = buf.take(cur) + action.ch + buf.drop(cur), + inputCursor = cur + 1, + ) to emptyList() + } - is Action.Backspace -> state.copy( - inputBuffer = state.inputBuffer.dropLast(1), - ) to emptyList() + is Action.Backspace -> { + val cur = state.inputCursor + if (cur > 0) { + val buf = state.inputBuffer + state.copy( + inputBuffer = buf.take(cur - 1) + buf.drop(cur), + inputCursor = cur - 1, + ) to emptyList() + } else { + state to emptyList() + } + } is Action.CancelInput -> state.copy( inputMode = InputMode.ROUTER, inputBuffer = "", + inputCursor = 0, ) to emptyList() is Action.SubmitInput -> state.copy( inputBuffer = "", + inputCursor = 0, ) to emptyList() is Action.SubmitApprovalDecision -> { diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt index cbd60c89..ae876b18 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt @@ -169,17 +169,23 @@ object RootReducer { 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], - ) + -1 -> { + val text = history[history.size - 1] + withBgReset.copy( + savedInputBuffer = withBgReset.inputBuffer, + inputHistoryIndex = history.size - 1, + inputBuffer = text, + inputCursor = text.length, + ) + } 0 -> withBgReset // at oldest entry, stay else -> { val newIndex = withBgReset.inputHistoryIndex - 1 + val text = history[newIndex] withBgReset.copy( inputHistoryIndex = newIndex, - inputBuffer = history[newIndex], + inputBuffer = text, + inputCursor = text.length, ) } } @@ -202,15 +208,21 @@ object RootReducer { 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, - ) + history.size - 1 -> { + val saved = withBgReset.savedInputBuffer + withBgReset.copy( + inputHistoryIndex = -1, + inputBuffer = saved, + inputCursor = saved.length, + ) + } else -> { val newIndex = withBgReset.inputHistoryIndex + 1 + val text = history[newIndex] withBgReset.copy( inputHistoryIndex = newIndex, - inputBuffer = history[newIndex], + inputBuffer = text, + inputCursor = text.length, ) } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt index b353688f..3cf574b0 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt @@ -51,12 +51,20 @@ object SessionsReducer { 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() + val wfIndex = sessions.selectedWorkflowIndex + when { + wfIndex in sessions.workflows.indices -> { + val wf = sessions.workflows[wfIndex] + sessions.copy(selectedWorkflowIndex = -1) to listOf( + Effect.SendWs(ClientMessage.StartSession(workflowId = wf.workflowId, config = null)), + ) + } + text.isNotBlank() -> { + sessions to listOf( + Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)), + ) + } + else -> sessions to emptyList() } } displayState == DisplayState.IN_SESSION -> { @@ -91,19 +99,58 @@ object SessionsReducer { else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) } private fun navigateUp(sessions: SessionsState): SessionsState { + val wfi = sessions.selectedWorkflowIndex + if (wfi >= 0) { + // Currently in the workflow picker + return if (wfi > 0) { + sessions.copy(selectedWorkflowIndex = wfi - 1) + } else { + // Move back to last session, or stay at first workflow if no sessions + val list = filteredSessions(sessions) + if (list.isNotEmpty()) { + sessions.copy(selectedWorkflowIndex = -1, selectedId = list.last().id) + } else { + sessions.copy(selectedWorkflowIndex = -1) + } + } + } val list = filteredSessions(sessions) - if (list.isEmpty()) return sessions + if (list.isEmpty()) { + // No sessions — move to last workflow + return if (sessions.workflows.isNotEmpty()) { + sessions.copy(selectedWorkflowIndex = sessions.workflows.lastIndex) + } else { + sessions + } + } val idx = list.indexOfFirst { it.id == sessions.selectedId } val newIdx = if (idx <= 0) list.lastIndex else idx - 1 - return sessions.copy(selectedId = list[newIdx].id) + return sessions.copy(selectedId = list[newIdx].id, selectedWorkflowIndex = -1) } private fun navigateDown(sessions: SessionsState): SessionsState { val list = filteredSessions(sessions) - if (list.isEmpty()) return sessions + if (list.isEmpty()) { + // No sessions — navigate workflows + val wfi = sessions.selectedWorkflowIndex + return if (sessions.workflows.isNotEmpty()) { + val next = if (wfi >= sessions.workflows.lastIndex) 0 else wfi + 1 + sessions.copy(selectedWorkflowIndex = next) + } else { + sessions + } + } val idx = list.indexOfFirst { it.id == sessions.selectedId } - val newIdx = if (idx >= list.lastIndex) 0 else idx + 1 - return sessions.copy(selectedId = list[newIdx].id) + if (idx >= list.lastIndex) { + // At bottom of sessions — move to first workflow + return if (sessions.workflows.isNotEmpty()) { + sessions.copy(selectedId = null, selectedWorkflowIndex = 0) + } else { + sessions.copy(selectedId = list[0].id) + } + } + val newIdx = idx + 1 + return sessions.copy(selectedId = list[newIdx].id, selectedWorkflowIndex = -1) } private fun formatTime(epochMs: Long): String = @@ -131,6 +178,7 @@ object SessionsReducer { is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg) is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg) is ServerMessage.StageToolManifest -> processStageToolManifestMessage(sessions, msg) + is ServerMessage.WorkflowList -> processWorkflowListMessage(sessions, msg) else -> sessions to emptyList() } val withBg = incrementBackgroundCountIfNonSelected(result, msg) @@ -318,7 +366,7 @@ object SessionsReducer { if (s.id == msg.sessionId.value) { val updatedTools = s.tools.map { t -> if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) { - t.copy(status = ToolDisplayStatus.COMPLETED) + t.copy(status = ToolDisplayStatus.COMPLETED, diff = msg.diff) } else { t } @@ -512,4 +560,10 @@ object SessionsReducer { if (s.id == sessionId) s.copy(status = status, lastEventAt = clock()) else s }, ) + + private fun processWorkflowListMessage( + sessions: SessionsState, + msg: ServerMessage.WorkflowList, + ): Pair> = + sessions.copy(workflows = msg.workflows) to emptyList() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt index e1edb7b1..fb355710 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/SessionsState.kt @@ -1,8 +1,13 @@ package com.correx.apps.tui.state +import com.correx.apps.server.protocol.WorkflowDto + data class SessionsState( val sessions: List = emptyList(), val selectedId: String? = null, val filter: String = "", val backgroundUpdateCount: Int = 0, + val workflows: List = emptyList(), + /** Index into [workflows] when focus is in the workflow picker; -1 = no workflow selected. */ + val selectedWorkflowIndex: Int = -1, ) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt index 7e1270d2..0144f2c0 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt @@ -14,6 +14,7 @@ data class TuiToolRecord( val tier: Int, val status: ToolDisplayStatus, val argsPreview: String?, + val diff: String? = null, ) data class TuiEventEntry( @@ -29,6 +30,7 @@ data class TuiState( val provider: ProviderState = ProviderState(), val inputMode: InputMode = InputMode.ROUTER, val inputBuffer: String = "", + val inputCursor: Int = 0, val eventStripVisible: Boolean = true, val inputHistory: Map> = emptyMap(), val inputHistoryIndex: Int = -1, diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt index b548974b..b2387981 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/InputReducerTest.kt @@ -23,9 +23,9 @@ class InputReducerTest { } @Test - fun `AppendChar appends character to buffer`() { + fun `AppendChar appends character at cursor position`() { val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab"), + state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab", inputCursor = 2), action = Action.AppendChar('c'), ) assertEquals("abc", state.inputBuffer) @@ -33,9 +33,20 @@ class InputReducerTest { } @Test - fun `Backspace drops last character`() { + fun `AppendChar inserts at cursor when not at end`() { val (state, effects) = reduce( - state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "abc"), + state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab", inputCursor = 1), + action = Action.AppendChar('x'), + ) + assertEquals("axb", state.inputBuffer) + assertEquals(2, state.inputCursor) + assertTrue(effects.isEmpty()) + } + + @Test + fun `Backspace drops character before cursor`() { + val (state, effects) = reduce( + state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "abc", inputCursor = 3), action = Action.Backspace, ) assertEquals("ab", state.inputBuffer) diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ToolReceipt.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ToolReceipt.kt index 49a8f3b8..35698795 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/ToolReceipt.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ToolReceipt.kt @@ -17,5 +17,6 @@ data class ToolReceipt( val affectedEntities: List = emptyList(), val durationMs: Long, val tier: Tier, - val timestamp: Instant + val timestamp: Instant, + val diff: String? = null, ) diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DiffUtil.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DiffUtil.kt new file mode 100644 index 00000000..382fd981 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/DiffUtil.kt @@ -0,0 +1,146 @@ +package com.correx.infrastructure.tools + +/** + * Produces a unified-diff-style string from old and new content. + * Uses LCS (Longest Common Subsequence) to find minimal edits. + * Context lines: 3 before and after each hunk. + */ +object DiffUtil { + + private const val CONTEXT_LINES = 3 + + fun unifiedDiff(oldContent: String, newContent: String, filePath: String? = null): String { + if (oldContent == newContent) return "" + + val oldLines = oldContent.lines() + val newLines = newContent.lines() + + val edits = computeEdits(oldLines, newLines) + if (edits.isEmpty()) return "" + + return formatHunks(edits, filePath ?: "file") + } + + // --- LCS-based edit computation --- + + private enum class Op { EQUAL, DELETE, INSERT } + + private data class Edit(val op: Op, val text: String) + + private fun computeEdits(oldLines: List, newLines: List): List { + val m = oldLines.size + val n = newLines.size + + // LCS dynamic programming table + val dp = Array(m + 1) { IntArray(n + 1) } + for (i in 1..m) { + for (j in 1..n) { + dp[i][j] = if (oldLines[i - 1] == newLines[j - 1]) { + dp[i - 1][j - 1] + 1 + } else { + maxOf(dp[i - 1][j], dp[i][j - 1]) + } + } + } + + // Backtrack through LCS table to build edit list + val result = mutableListOf() + var i = m + var j = n + val stack = ArrayDeque() + while (i > 0 || j > 0) { + when { + i > 0 && j > 0 && oldLines[i - 1] == newLines[j - 1] -> { + stack.addFirst(Edit(Op.EQUAL, newLines[j - 1])) + i-- + j-- + } + + j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) -> { + stack.addFirst(Edit(Op.INSERT, newLines[j - 1])) + j-- + } + + i > 0 -> { + stack.addFirst(Edit(Op.DELETE, oldLines[i - 1])) + i-- + } + } + } + result.addAll(stack) + return result + } + + // --- Hunk formatting --- + + private fun formatHunks(edits: List, filePath: String): String { + val hunks = buildList { + var i = 0 + while (i < edits.size) { + if (edits[i].op != Op.EQUAL) { + // Find start of hunk: go back CONTEXT_LINES equal lines + val hunkStart = maxOf(findHunkStart(edits, i), 0) + // Find end of hunk: go forward CONTEXT_LINES equal lines after last change + val hunkEnd = findHunkEnd(edits, i) + add(Triple(hunkStart, hunkEnd, edits.subList(hunkStart, hunkEnd))) + i = hunkEnd + } else { + i++ + } + } + } + + if (hunks.isEmpty()) return "" + + val sb = StringBuilder() + sb.appendLine("--- a/$filePath") + sb.appendLine("+++ b/$filePath") + + for ((start, end, hunkEdits) in hunks) { + val oldStart = countOldLinesBefore(edits, start) + 1 + val oldCount = countOpInRange(hunkEdits, Op.EQUAL) + countOpInRange(hunkEdits, Op.DELETE) + val newStart = countNewLinesBefore(edits, start) + 1 + val newCount = countOpInRange(hunkEdits, Op.EQUAL) + countOpInRange(hunkEdits, Op.INSERT) + + sb.appendLine("@@ -$oldStart,$oldCount +$newStart,$newCount @@") + for (edit in hunkEdits) { + when (edit.op) { + Op.EQUAL -> sb.appendLine(" ${edit.text}") + Op.DELETE -> sb.appendLine("-${edit.text}") + Op.INSERT -> sb.appendLine("+${edit.text}") + } + } + } + + return sb.toString().trimEnd() + } + + private fun findHunkStart(edits: List, changeIdx: Int): Int { + var idx = changeIdx + var ctx = CONTEXT_LINES + while (idx > 0 && ctx > 0) { + idx-- + if (edits[idx].op == Op.EQUAL) ctx-- + } + return idx + } + + private fun findHunkEnd(edits: List, changeIdx: Int): Int { + var idx = changeIdx + var ctx = CONTEXT_LINES + while (idx < edits.size && (edits[idx].op != Op.EQUAL || ctx > 0)) { + if (edits[idx].op == Op.EQUAL) ctx-- + idx++ + } + return idx + } + + private fun countOldLinesBefore(edits: List, upTo: Int): Int = + edits.subList(0, upTo).count { it.op != Op.INSERT } + + private fun countNewLinesBefore(edits: List, upTo: Int): Int = + edits.subList(0, upTo).count { it.op != Op.DELETE } + + private fun countOpInRange(edits: List, op: Op): Int = + edits.count { it.op == op } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt index 50f47f0f..4b10fc2a 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/SandboxedToolExecutor.kt @@ -70,9 +70,10 @@ class SandboxedToolExecutor( when (val result = delegate.execute(request)) { is ToolResult.Success -> { + val diff = computeDiff(affectedPaths, backupMap) restoreOrClean(backupMap, success = true) cleanWorkingDir(workingDir) - emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs()) + emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs(), diff) result } @@ -148,6 +149,7 @@ class SandboxedToolExecutor( tool: Tool, affectedPaths: Set, durationMs: Long, + diff: String? = null, ) { val affectedEntities = affectedPaths.map { it.toString() } emit( @@ -166,11 +168,43 @@ class SandboxedToolExecutor( durationMs = durationMs, tier = tool.tier, timestamp = Clock.System.now(), + diff = diff, ), ), ) } + @Suppress("MaxLineLength") + private fun computeDiff(affectedPaths: Set, backupMap: Map): String? { + val diffs = mutableListOf() + + // Files that existed before the tool ran (backed up) + for ((original, backup) in backupMap) { + runCatching { + val oldContent = Files.readString(backup) + val newContent = if (Files.exists(original)) Files.readString(original) else "" + if (oldContent != newContent) { + diffs.add(DiffUtil.unifiedDiff(oldContent, newContent, original.toString())) + } + } + } + + // Newly created files (no backup, but affected and now exist) + for (path in affectedPaths) { + if (!backupMap.containsKey(path) && Files.exists(path)) { + runCatching { + val newContent = Files.readString(path) + if (newContent.isNotEmpty()) { + diffs.add(DiffUtil.unifiedDiff("", newContent, path.toString())) + } + } + } + } + + if (diffs.isEmpty()) return null + return diffs.joinToString("\n") + } + private suspend fun emitFailed( sessionId: SessionId, invocationId: ToolInvocationId,