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:
@@ -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 }
|
||||
}
|
||||
+35
-1
@@ -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<Path>,
|
||||
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<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(
|
||||
sessionId: SessionId,
|
||||
invocationId: ToolInvocationId,
|
||||
|
||||
Reference in New Issue
Block a user