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
@@ -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)) {
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,