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] 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? { open fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
if (resolved.putIfAbsent(msg.requestId, true) != null) { if (resolved.putIfAbsent(msg.requestId, true) != null) {
return ServerMessage.ProtocolError( return ServerMessage.ProtocolError(
@@ -119,6 +119,7 @@ suspend fun domainEventToServerMessage(
toolName = p.toolName, toolName = p.toolName,
outputSummary = p.receipt.outputSummary, outputSummary = p.receipt.outputSummary,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(), occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
diff = p.receipt.diff,
sequence = seq, sequence = seq,
sessionSequence = sessionSequence, sessionSequence = sessionSequence,
) )
@@ -1,5 +1,6 @@
package com.correx.apps.server.bridge 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.ApprovalDto
import com.correx.apps.server.protocol.EventEntryDto import com.correx.apps.server.protocol.EventEntryDto
import com.correx.apps.server.protocol.ServerMessage 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.events.WorkflowFailedEvent
import com.correx.core.events.orchestration.OrchestrationStatus import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.events.stores.EventStore 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.events.types.SessionId
import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.tools.registry.ToolRegistry import com.correx.core.tools.registry.ToolRegistry
@@ -36,6 +38,7 @@ class SessionEventBridge(
private val approvalRepository: DefaultApprovalRepository, private val approvalRepository: DefaultApprovalRepository,
private val workflowRegistry: WorkflowRegistry, private val workflowRegistry: WorkflowRegistry,
private val toolRegistry: ToolRegistry, private val toolRegistry: ToolRegistry,
private val approvalCoordinator: ApprovalCoordinator? = null,
private val send: suspend (ServerMessage) -> Unit, private val send: suspend (ServerMessage) -> Unit,
) { ) {
suspend fun replaySnapshot() { suspend fun replaySnapshot() {
@@ -67,6 +70,12 @@ class SessionEventBridge(
.mapNotNull { eventToEntry(it) } .mapNotNull { eventToEntry(it) }
.takeLast(7) .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( send(ServerMessage.SessionSnapshot(
sessionId = sessionId, sessionId = sessionId,
workflowId = orchState.workflowId, workflowId = orchState.workflowId,
@@ -61,3 +61,9 @@ data class EventEntryDto(
val type: String, val type: String,
val detail: 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 toolName: String,
val outputSummary: String, val outputSummary: String,
val occurredAt: Long, val occurredAt: Long,
val diff: String? = null,
override val sequence: Long, override val sequence: Long,
override val sessionSequence: Long, override val sessionSequence: Long,
) : ServerMessage, SessionMessage ) : ServerMessage, SessionMessage
@@ -261,4 +262,14 @@ sealed interface ServerMessage {
override val sequence: Long? get() = null override val sequence: Long? get() = null
override val sessionSequence: 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.ProtocolSerializer
import com.correx.apps.server.protocol.ProviderHealthDto import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.apps.server.protocol.ServerMessage 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.StageToolDecl
import com.correx.apps.server.protocol.ToolDecl import com.correx.apps.server.protocol.ToolDecl
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
@@ -54,6 +55,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
workflowRegistry = module.workflowRegistry, workflowRegistry = module.workflowRegistry,
toolRegistry = module.toolRegistry, toolRegistry = module.toolRegistry,
send = sendFrame, send = sendFrame,
approvalCoordinator = module.approvalCoordinator,
) )
val mapper = DomainEventMapper(module.artifactStore) val mapper = DomainEventMapper(module.artifactStore)
@@ -117,6 +119,15 @@ class GlobalStreamHandler(private val module: ServerModule) {
) )
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg))) 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( private suspend fun handleClientMessage(
@@ -307,7 +307,7 @@ class DomainEventMapperTest {
) )
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals( 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, result,
) )
} }
@@ -17,4 +17,6 @@ sealed class KeyEvent {
object ReturnToSessionList : KeyEvent() object ReturnToSessionList : KeyEvent()
object ShowPendingApproval : KeyEvent() object ShowPendingApproval : KeyEvent()
data class CharInput(val ch: Char) : KeyEvent() data class CharInput(val ch: Char) : KeyEvent()
object CursorLeft : KeyEvent()
object CursorRight : KeyEvent()
} }
@@ -1,11 +1,13 @@
package com.correx.apps.tui package com.correx.apps.tui
import com.correx.apps.tui.components.approvalSurfaceWidget 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.eventHistoryStripWidget
import com.correx.apps.tui.components.inputBarWidget import com.correx.apps.tui.components.inputBarWidget
import com.correx.apps.tui.components.routerPanelWidget import com.correx.apps.tui.components.routerPanelWidget
import com.correx.apps.tui.components.sessionListWidget import com.correx.apps.tui.components.sessionListWidget
import com.correx.apps.tui.components.statusBarWidget 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.Action
import com.correx.apps.tui.input.KeyResolver import com.correx.apps.tui.input.KeyResolver
import com.correx.apps.tui.input.mapKey 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.reducer.RootReducer
import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.TuiState 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.displayState
import com.correx.apps.tui.state.selectedPendingApproval import com.correx.apps.tui.state.selectedPendingApproval
import com.correx.apps.tui.ws.ConnectionEvent 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 DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 1 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 const val INPUT_HEIGHT = 4
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main") 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) { private fun renderIdleLayout(frame: Frame, state: TuiState) {
val area = frame.area() val area = frame.area()
val vertRects = Layout.vertical() val hasWorkflows = state.sessions.workflows.isNotEmpty()
.constraints( val vertRects = if (hasWorkflows) {
Constraint.length(STATUS_HEIGHT), Layout.vertical()
Constraint.fill(), .constraints(
Constraint.length(INPUT_HEIGHT), Constraint.length(STATUS_HEIGHT),
) Constraint.fill(),
.split(area) 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(statusBarWidget(state), vertRects[0])
frame.renderWidget(sessionListWidget(state), vertRects[1]) 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) { private fun renderInSessionLayout(frame: Frame, state: TuiState) {
val area = frame.area() 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( val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT), Constraint.length(STATUS_HEIGHT),
) )
if (state.eventStripVisible) { if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT)) constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
} }
if (hasDiff) {
constraints.add(Constraint.length(DIFF_HEIGHT))
}
constraints.add(Constraint.fill()) constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT)) constraints.add(Constraint.length(INPUT_HEIGHT))
@@ -158,6 +188,9 @@ private fun renderInSessionLayout(frame: Frame, state: TuiState) {
if (state.eventStripVisible) { if (state.eventStripVisible) {
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++]) frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
} }
if (hasDiff) {
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++]) frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx]) frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx])
} }
@@ -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<Line> {
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()
}
@@ -35,20 +35,30 @@ fun eventHistoryStripWidget(state: TuiState): Paragraph {
add(Line.from(Span.styled(stageInfo, dimStyle))) add(Line.from(Span.styled(stageInfo, dimStyle)))
// Recent events (plain text, compact format) — capped to fit allocated height. // Recent events (plain text, compact format) — capped to fit allocated height.
// Line 0 = stage info, lines 1-3 = events (max 3). // Line 0 = stage info, lines 1-5 = events (max 5).
val events = selectedSession.recentEvents.takeLast(3) val events = selectedSession.recentEvents.takeLast(5)
if (events.isEmpty()) { if (events.isEmpty()) {
add(Line.from(Span.styled("no events", dimStyle))) add(Line.from(Span.styled("no events", dimStyle)))
} else { } else {
for (ev in events) { for (ev in events) {
val typePadded = ev.type.padEnd(TYPE_WIDTH, ' ') 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) { val detail = if (ev.detail.length > DETAIL_MAX) {
ev.detail.take(DETAIL_MAX - 3) + "..." ev.detail.take(DETAIL_MAX - 3) + "..."
} else { } else {
ev.detail ev.detail
} }
val row = "[${ev.timestamp}] [$typePadded] $detail" add(Line.from(
add(Line.from(Span.raw(row))) Span.styled("[${ev.timestamp}] ", dimStyle),
Span.styled("[$typePadded]", typeStyle),
Span.raw(" $detail"),
))
} }
} }
} }
@@ -13,6 +13,23 @@ import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph 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 { fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph {
val dimStyle = Style.create().dim().gray() val dimStyle = Style.create().dim().gray()
@@ -53,15 +70,23 @@ private fun createRowsForIdleRouter(
dimStyle: Style?, dimStyle: Style?,
sessionName: String, sessionName: String,
): Pair<Line?, Line?> { ): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) { val row1 = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer)) cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else { } 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( return row1 to Line.from(
"$sessionName · router tab filter ctrl+n new ctrl+e events ctrl+q", Span.styled(sessionName, Style.create().cyan()),
dimStyle, 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( private fun createRowsForInSessionRouter(
@@ -69,47 +94,73 @@ private fun createRowsForInSessionRouter(
dimStyle: Style?, dimStyle: Style?,
sessionName: String, sessionName: String,
): Pair<Line?, Line?> { ): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) { val row1 = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer)) cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else { } 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( return row1 to Line.from(
"$sessionName · router tab filter ↑↓ history ctrl+e events ctrl+l back ctrl+q", Span.styled(sessionName, Style.create().cyan()),
dimStyle, 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( private fun createRowsForApprovalRouter(
state: TuiState, state: TuiState,
dimStyle: Style?, dimStyle: Style?,
): Pair<Line?, Line?> { ): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) { val row1 = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer)) cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else { } 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) { val decisionSpans = when (state.pendingDecision) {
null -> "" null -> listOf<Span>()
ApprovalDecision.APPROVE -> " [decision: APPROVE]" ApprovalDecision.APPROVE -> listOf(Span.styled(" [decision: ", dimStyle), Span.styled("APPROVE", Style.create().green()), Span.styled("]", dimStyle))
ApprovalDecision.REJECT -> " [decision: REJECT]" ApprovalDecision.REJECT -> listOf(Span.styled(" [decision: ", dimStyle), Span.styled("REJECT", Style.create().red()), Span.styled("]", dimStyle))
} }
return cursor to Line.from(Span.styled( return row1 to Line.from(
"approval · ctrl+a approve ctrl+r reject ↵ enter submit esc dismiss$decisionIndicator", Span.styled("approval", Style.create().yellow()),
dimStyle, 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( private fun createRowsForFilter(
state: TuiState, state: TuiState,
dimStyle: Style?, dimStyle: Style?,
): Pair<Line?, Line?> { ): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) { val row1 = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer)) cursorLine(state.inputBuffer, state.inputCursor, InputMode.FILTER)
} else { } 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),
)
} }
@@ -60,7 +60,6 @@ private fun statusSpan(s: SessionSummary): Span {
fun filteredSessions(sessions: SessionsState): List<SessionSummary> { fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
val f = sessions.filter val f = sessions.filter
val sorted = sessions.sessions.sortedByDescending { it.lastEventAt } return if (f.isEmpty()) sessions.sessions
return if (f.isEmpty()) sorted else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) }
else sorted.filter { it.workflowId.contains(f, ignoreCase = true) }
} }
@@ -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<Line> {
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()
}
@@ -24,5 +24,7 @@ sealed interface Action {
data object Disconnected : Action data object Disconnected : Action
data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : Action data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : Action
data object CycleMode : Action data object CycleMode : Action
data object CursorLeft : Action
data object CursorRight : Action
data object NoOp : Action data object NoOp : Action
} }
@@ -21,6 +21,10 @@ object KeyResolver {
} }
if (global != null) return global 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 // FILTER mode override: ↑↓ always navigates sessions list
if (inputMode == InputMode.FILTER) { if (inputMode == InputMode.FILTER) {
return when (key) { return when (key) {
@@ -29,6 +29,8 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
return when (event.code()) { return when (event.code()) {
KeyCode.UP -> KeyEvent.NavUp KeyCode.UP -> KeyEvent.NavUp
KeyCode.DOWN -> KeyEvent.NavDown KeyCode.DOWN -> KeyEvent.NavDown
KeyCode.LEFT -> KeyEvent.CursorLeft
KeyCode.RIGHT -> KeyEvent.CursorRight
KeyCode.ENTER -> KeyEvent.Enter KeyCode.ENTER -> KeyEvent.Enter
KeyCode.ESCAPE -> KeyEvent.Escape KeyCode.ESCAPE -> KeyEvent.Escape
KeyCode.BACKSPACE -> KeyEvent.Backspace KeyCode.BACKSPACE -> KeyEvent.Backspace
@@ -21,26 +21,51 @@ object InputReducer {
}, },
) to emptyList() ) 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( is Action.OpenNewSessionPrompt -> state.copy(
inputMode = InputMode.ROUTER, inputMode = InputMode.ROUTER,
inputBuffer = "", inputBuffer = "",
inputCursor = 0,
) to emptyList() ) to emptyList()
is Action.AppendChar -> state.copy( is Action.AppendChar -> {
inputBuffer = state.inputBuffer + action.ch, val buf = state.inputBuffer
) to emptyList() 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( is Action.Backspace -> {
inputBuffer = state.inputBuffer.dropLast(1), val cur = state.inputCursor
) to emptyList() 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( is Action.CancelInput -> state.copy(
inputMode = InputMode.ROUTER, inputMode = InputMode.ROUTER,
inputBuffer = "", inputBuffer = "",
inputCursor = 0,
) to emptyList() ) to emptyList()
is Action.SubmitInput -> state.copy( is Action.SubmitInput -> state.copy(
inputBuffer = "", inputBuffer = "",
inputCursor = 0,
) to emptyList() ) to emptyList()
is Action.SubmitApprovalDecision -> { is Action.SubmitApprovalDecision -> {
@@ -169,17 +169,23 @@ object RootReducer {
val history = withBgReset.inputHistory[selectedId] ?: emptyList() val history = withBgReset.inputHistory[selectedId] ?: emptyList()
if (history.isNotEmpty()) { if (history.isNotEmpty()) {
when (withBgReset.inputHistoryIndex) { when (withBgReset.inputHistoryIndex) {
-1 -> withBgReset.copy( -1 -> {
savedInputBuffer = withBgReset.inputBuffer, val text = history[history.size - 1]
inputHistoryIndex = history.size - 1, withBgReset.copy(
inputBuffer = history[history.size - 1], savedInputBuffer = withBgReset.inputBuffer,
) inputHistoryIndex = history.size - 1,
inputBuffer = text,
inputCursor = text.length,
)
}
0 -> withBgReset // at oldest entry, stay 0 -> withBgReset // at oldest entry, stay
else -> { else -> {
val newIndex = withBgReset.inputHistoryIndex - 1 val newIndex = withBgReset.inputHistoryIndex - 1
val text = history[newIndex]
withBgReset.copy( withBgReset.copy(
inputHistoryIndex = newIndex, inputHistoryIndex = newIndex,
inputBuffer = history[newIndex], inputBuffer = text,
inputCursor = text.length,
) )
} }
} }
@@ -202,15 +208,21 @@ object RootReducer {
val history = withBgReset.inputHistory[selectedId] ?: emptyList() val history = withBgReset.inputHistory[selectedId] ?: emptyList()
when (withBgReset.inputHistoryIndex) { when (withBgReset.inputHistoryIndex) {
-1 -> withBgReset // already at current buffer -1 -> withBgReset // already at current buffer
history.size - 1 -> withBgReset.copy( history.size - 1 -> {
inputHistoryIndex = -1, val saved = withBgReset.savedInputBuffer
inputBuffer = withBgReset.savedInputBuffer, withBgReset.copy(
) inputHistoryIndex = -1,
inputBuffer = saved,
inputCursor = saved.length,
)
}
else -> { else -> {
val newIndex = withBgReset.inputHistoryIndex + 1 val newIndex = withBgReset.inputHistoryIndex + 1
val text = history[newIndex]
withBgReset.copy( withBgReset.copy(
inputHistoryIndex = newIndex, inputHistoryIndex = newIndex,
inputBuffer = history[newIndex], inputBuffer = text,
inputCursor = text.length,
) )
} }
} }
@@ -51,12 +51,20 @@ object SessionsReducer {
inputMode == InputMode.FILTER -> sessions.copy(filter = inputText) to emptyList() inputMode == InputMode.FILTER -> sessions.copy(filter = inputText) to emptyList()
displayState == DisplayState.IDLE -> { displayState == DisplayState.IDLE -> {
val text = inputText.trim() val text = inputText.trim()
if (text.isNotBlank()) { val wfIndex = sessions.selectedWorkflowIndex
sessions to listOf( when {
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)), wfIndex in sessions.workflows.indices -> {
) val wf = sessions.workflows[wfIndex]
} else { sessions.copy(selectedWorkflowIndex = -1) to listOf(
sessions to emptyList() 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 -> { displayState == DisplayState.IN_SESSION -> {
@@ -91,19 +99,58 @@ object SessionsReducer {
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) } else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
private fun navigateUp(sessions: SessionsState): SessionsState { 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) 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 idx = list.indexOfFirst { it.id == sessions.selectedId }
val newIdx = if (idx <= 0) list.lastIndex else idx - 1 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 { private fun navigateDown(sessions: SessionsState): SessionsState {
val list = filteredSessions(sessions) 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 idx = list.indexOfFirst { it.id == sessions.selectedId }
val newIdx = if (idx >= list.lastIndex) 0 else idx + 1 if (idx >= list.lastIndex) {
return sessions.copy(selectedId = list[newIdx].id) // 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 = private fun formatTime(epochMs: Long): String =
@@ -131,6 +178,7 @@ object SessionsReducer {
is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg) is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg)
is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg) is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg)
is ServerMessage.StageToolManifest -> processStageToolManifestMessage(sessions, msg) is ServerMessage.StageToolManifest -> processStageToolManifestMessage(sessions, msg)
is ServerMessage.WorkflowList -> processWorkflowListMessage(sessions, msg)
else -> sessions to emptyList() else -> sessions to emptyList()
} }
val withBg = incrementBackgroundCountIfNonSelected(result, msg) val withBg = incrementBackgroundCountIfNonSelected(result, msg)
@@ -318,7 +366,7 @@ object SessionsReducer {
if (s.id == msg.sessionId.value) { if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t -> val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) { if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.COMPLETED) t.copy(status = ToolDisplayStatus.COMPLETED, diff = msg.diff)
} else { } else {
t t
} }
@@ -512,4 +560,10 @@ object SessionsReducer {
if (s.id == sessionId) s.copy(status = status, lastEventAt = clock()) else s if (s.id == sessionId) s.copy(status = status, lastEventAt = clock()) else s
}, },
) )
private fun processWorkflowListMessage(
sessions: SessionsState,
msg: ServerMessage.WorkflowList,
): Pair<SessionsState, List<Effect>> =
sessions.copy(workflows = msg.workflows) to emptyList()
} }
@@ -1,8 +1,13 @@
package com.correx.apps.tui.state package com.correx.apps.tui.state
import com.correx.apps.server.protocol.WorkflowDto
data class SessionsState( data class SessionsState(
val sessions: List<SessionSummary> = emptyList(), val sessions: List<SessionSummary> = emptyList(),
val selectedId: String? = null, val selectedId: String? = null,
val filter: String = "", val filter: String = "",
val backgroundUpdateCount: Int = 0, val backgroundUpdateCount: Int = 0,
val workflows: List<WorkflowDto> = emptyList(),
/** Index into [workflows] when focus is in the workflow picker; -1 = no workflow selected. */
val selectedWorkflowIndex: Int = -1,
) )
@@ -14,6 +14,7 @@ data class TuiToolRecord(
val tier: Int, val tier: Int,
val status: ToolDisplayStatus, val status: ToolDisplayStatus,
val argsPreview: String?, val argsPreview: String?,
val diff: String? = null,
) )
data class TuiEventEntry( data class TuiEventEntry(
@@ -29,6 +30,7 @@ data class TuiState(
val provider: ProviderState = ProviderState(), val provider: ProviderState = ProviderState(),
val inputMode: InputMode = InputMode.ROUTER, val inputMode: InputMode = InputMode.ROUTER,
val inputBuffer: String = "", val inputBuffer: String = "",
val inputCursor: Int = 0,
val eventStripVisible: Boolean = true, val eventStripVisible: Boolean = true,
val inputHistory: Map<String, List<String>> = emptyMap(), val inputHistory: Map<String, List<String>> = emptyMap(),
val inputHistoryIndex: Int = -1, val inputHistoryIndex: Int = -1,
@@ -23,9 +23,9 @@ class InputReducerTest {
} }
@Test @Test
fun `AppendChar appends character to buffer`() { fun `AppendChar appends character at cursor position`() {
val (state, effects) = reduce( val (state, effects) = reduce(
state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab"), state = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab", inputCursor = 2),
action = Action.AppendChar('c'), action = Action.AppendChar('c'),
) )
assertEquals("abc", state.inputBuffer) assertEquals("abc", state.inputBuffer)
@@ -33,9 +33,20 @@ class InputReducerTest {
} }
@Test @Test
fun `Backspace drops last character`() { fun `AppendChar inserts at cursor when not at end`() {
val (state, effects) = reduce( 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, action = Action.Backspace,
) )
assertEquals("ab", state.inputBuffer) assertEquals("ab", state.inputBuffer)
@@ -17,5 +17,6 @@ data class ToolReceipt(
val affectedEntities: List<String> = emptyList(), val affectedEntities: List<String> = emptyList(),
val durationMs: Long, val durationMs: Long,
val tier: Tier, val tier: Tier,
val timestamp: Instant val timestamp: Instant,
val diff: String? = null,
) )
@@ -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<String>, newLines: List<String>): List<Edit> {
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<Edit>()
var i = m
var j = n
val stack = ArrayDeque<Edit>()
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<Edit>, 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<Edit>, 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<Edit>, 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<Edit>, upTo: Int): Int =
edits.subList(0, upTo).count { it.op != Op.INSERT }
private fun countNewLinesBefore(edits: List<Edit>, upTo: Int): Int =
edits.subList(0, upTo).count { it.op != Op.DELETE }
private fun countOpInRange(edits: List<Edit>, op: Op): Int =
edits.count { it.op == op }
}
@@ -70,9 +70,10 @@ class SandboxedToolExecutor(
when (val result = delegate.execute(request)) { when (val result = delegate.execute(request)) {
is ToolResult.Success -> { is ToolResult.Success -> {
val diff = computeDiff(affectedPaths, backupMap)
restoreOrClean(backupMap, success = true) restoreOrClean(backupMap, success = true)
cleanWorkingDir(workingDir) cleanWorkingDir(workingDir)
emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs()) emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs(), diff)
result result
} }
@@ -148,6 +149,7 @@ class SandboxedToolExecutor(
tool: Tool, tool: Tool,
affectedPaths: Set<Path>, affectedPaths: Set<Path>,
durationMs: Long, durationMs: Long,
diff: String? = null,
) { ) {
val affectedEntities = affectedPaths.map { it.toString() } val affectedEntities = affectedPaths.map { it.toString() }
emit( emit(
@@ -166,11 +168,43 @@ class SandboxedToolExecutor(
durationMs = durationMs, durationMs = durationMs,
tier = tool.tier, tier = tool.tier,
timestamp = Clock.System.now(), timestamp = Clock.System.now(),
diff = diff,
), ),
), ),
) )
} }
@Suppress("MaxLineLength")
private fun computeDiff(affectedPaths: Set<Path>, backupMap: Map<Path, Path>): String? {
val diffs = mutableListOf<String>()
// 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( private suspend fun emitFailed(
sessionId: SessionId, sessionId: SessionId,
invocationId: ToolInvocationId, invocationId: ToolInvocationId,