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:
@@ -76,6 +76,15 @@ open class ApprovalCoordinator(
|
||||
|
||||
fun lookupSession(requestId: ApprovalRequestId): SessionId? = requestSessions[requestId]
|
||||
|
||||
/**
|
||||
* Register a pending approval request that was reconstructed from the persisted event store
|
||||
* during snapshot replay. This makes [lookupSession] work for approvals that were created
|
||||
* before the current connection's live stream started.
|
||||
*/
|
||||
fun registerPendingRequest(requestId: ApprovalRequestId, sessionId: SessionId) {
|
||||
requestSessions[requestId] = sessionId
|
||||
}
|
||||
|
||||
open fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
|
||||
if (resolved.putIfAbsent(msg.requestId, true) != null) {
|
||||
return ServerMessage.ProtocolError(
|
||||
|
||||
@@ -119,6 +119,7 @@ suspend fun domainEventToServerMessage(
|
||||
toolName = p.toolName,
|
||||
outputSummary = p.receipt.outputSummary,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
diff = p.receipt.diff,
|
||||
sequence = seq,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.approval.ApprovalCoordinator
|
||||
import com.correx.apps.server.protocol.ApprovalDto
|
||||
import com.correx.apps.server.protocol.EventEntryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
@@ -25,6 +26,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
@@ -36,6 +38,7 @@ class SessionEventBridge(
|
||||
private val approvalRepository: DefaultApprovalRepository,
|
||||
private val workflowRegistry: WorkflowRegistry,
|
||||
private val toolRegistry: ToolRegistry,
|
||||
private val approvalCoordinator: ApprovalCoordinator? = null,
|
||||
private val send: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
suspend fun replaySnapshot() {
|
||||
@@ -67,6 +70,12 @@ class SessionEventBridge(
|
||||
.mapNotNull { eventToEntry(it) }
|
||||
.takeLast(7)
|
||||
|
||||
// Re-register pending approvals so the ApprovalCoordinator can route responses
|
||||
// from clients that connected after the ApprovalRequestedEvent was emitted.
|
||||
pendingApprovals.forEach { dto ->
|
||||
approvalCoordinator?.registerPendingRequest(ApprovalRequestId(dto.requestId), sessionId)
|
||||
}
|
||||
|
||||
send(ServerMessage.SessionSnapshot(
|
||||
sessionId = sessionId,
|
||||
workflowId = orchState.workflowId,
|
||||
|
||||
@@ -61,3 +61,9 @@ data class EventEntryDto(
|
||||
val type: String,
|
||||
val detail: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorkflowDto(
|
||||
val workflowId: String,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
@@ -171,6 +171,7 @@ sealed interface ServerMessage {
|
||||
val toolName: String,
|
||||
val outputSummary: String,
|
||||
val occurredAt: Long,
|
||||
val diff: String? = null,
|
||||
override val sequence: Long,
|
||||
override val sessionSequence: Long,
|
||||
) : ServerMessage, SessionMessage
|
||||
@@ -261,4 +262,14 @@ sealed interface ServerMessage {
|
||||
override val sequence: Long? get() = null
|
||||
override val sessionSequence: Long? get() = null
|
||||
}
|
||||
|
||||
// -- Workflow discovery --
|
||||
|
||||
@Serializable
|
||||
@SerialName("workflow.list")
|
||||
data class WorkflowList(
|
||||
val workflows: List<WorkflowDto>,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||
import com.correx.apps.server.protocol.ProviderHealthDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.WorkflowDto
|
||||
import com.correx.apps.server.protocol.StageToolDecl
|
||||
import com.correx.apps.server.protocol.ToolDecl
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
@@ -54,6 +55,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
workflowRegistry = module.workflowRegistry,
|
||||
toolRegistry = module.toolRegistry,
|
||||
send = sendFrame,
|
||||
approvalCoordinator = module.approvalCoordinator,
|
||||
)
|
||||
val mapper = DomainEventMapper(module.artifactStore)
|
||||
|
||||
@@ -117,6 +119,15 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg)))
|
||||
}
|
||||
|
||||
// Send available workflows so the TUI can show a workflow picker
|
||||
val workflows = module.workflowRegistry.listAll().map { summary ->
|
||||
WorkflowDto(workflowId = summary.workflowId, description = summary.description)
|
||||
}
|
||||
log.debug("workflow list: {} workflow(s)", workflows.size)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(
|
||||
ServerMessage.WorkflowList(workflows = workflows),
|
||||
)))
|
||||
}
|
||||
|
||||
private suspend fun handleClientMessage(
|
||||
|
||||
@@ -307,7 +307,7 @@ class DomainEventMapperTest {
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
|
||||
assertEquals(
|
||||
ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines", occurredAt, event.sequence, 0L),
|
||||
ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines", occurredAt, diff = null, event.sequence, 0L),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
// 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"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Line?, Line?> {
|
||||
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<Line?, Line?> {
|
||||
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<Line?, Line?> {
|
||||
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<Span>()
|
||||
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<Line?, Line?> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,6 @@ private fun statusSpan(s: SessionSummary): Span {
|
||||
|
||||
fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -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 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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SessionsState, List<Effect>> =
|
||||
sessions.copy(workflows = msg.workflows) to emptyList()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.correx.apps.tui.state
|
||||
|
||||
import com.correx.apps.server.protocol.WorkflowDto
|
||||
|
||||
data class SessionsState(
|
||||
val sessions: List<SessionSummary> = emptyList(),
|
||||
val selectedId: String? = null,
|
||||
val filter: String = "",
|
||||
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 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<String, List<String>> = emptyMap(),
|
||||
val inputHistoryIndex: Int = -1,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,5 +17,6 @@ data class ToolReceipt(
|
||||
val affectedEntities: List<String> = emptyList(),
|
||||
val durationMs: Long,
|
||||
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 }
|
||||
}
|
||||
+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