diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt index aa43089b..3188e3c8 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt @@ -27,6 +27,7 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph { val pendingDecision = state.pendingDecision val diffExpanded = state.diffExpanded + val scrollOffset = state.diffScrollOffset val lines = buildList { if (approval == null) { @@ -45,8 +46,11 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph { if (preview != null) { val previewLines = preview.lines() if (previewLines.isNotEmpty()) { + val totalLines = previewLines.size val visibleLines = if (diffExpanded) { - previewLines + val from = scrollOffset.coerceIn(0, maxOf(0, totalLines - 1)) + val to = minOf(from + PREVIEW_MAX_LINES, totalLines) + previewLines.subList(from, to) } else { previewLines.take(PREVIEW_MAX_LINES) } @@ -69,12 +73,13 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph { add(Line.from(Span.raw(" $line"))) } } - if (!diffExpanded && previewLines.size > PREVIEW_MAX_LINES) { - val remaining = previewLines.size - PREVIEW_MAX_LINES - add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand)", dimStyle))) + if (!diffExpanded && totalLines > PREVIEW_MAX_LINES) { + val remaining = totalLines - PREVIEW_MAX_LINES + add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand, \u2191\u2193 to scroll)", dimStyle))) } if (diffExpanded) { - add(Line.from(Span.styled(" (full diff — ctrl+x to collapse)", dimStyle))) + val showingTo = minOf(scrollOffset + PREVIEW_MAX_LINES, totalLines) + add(Line.from(Span.styled(" lines ${scrollOffset + 1}\u2013$showingTo of $totalLines — \u2191\u2193 scroll, ctrl+x collapse", dimStyle))) } } else { add(Line.from(Span.styled(" no preview available", dimStyle))) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt index c1aa61a5..f2e833d8 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt @@ -72,6 +72,8 @@ object KeyResolver { KeyEvent.Enter -> Action.SubmitApprovalDecision KeyEvent.Escape -> Action.CancelInput KeyEvent.Cancel -> Action.CancelInput + KeyEvent.NavUp -> Action.NavigateUp + KeyEvent.NavDown -> Action.NavigateDown KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip KeyEvent.ToggleDiffExpand -> Action.ToggleDiffExpand diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt index c9855420..725ec8c8 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt @@ -118,7 +118,10 @@ object RootReducer { else withBgReset.copy(eventStripVisible = !withBgReset.eventStripVisible) } - is Action.ToggleDiffExpand -> withBgReset.copy(diffExpanded = !withBgReset.diffExpanded) + is Action.ToggleDiffExpand -> withBgReset.copy( + diffExpanded = !withBgReset.diffExpanded, + diffScrollOffset = 0, + ) // Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1), // or enter a selected session from the list in IDLE. @@ -188,9 +191,11 @@ object RootReducer { } } - // Input history navigation on NavigateUp in IN_SESSION + ROUTER (ARCH-HISTORY-2/3). + // Diff scrolling in APPROVAL mode when expanded. is Action.NavigateUp -> { - if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) { + if (withBgReset.displayState == DisplayState.APPROVAL && withBgReset.diffExpanded) { + withBgReset.copy(diffScrollOffset = maxOf(0, withBgReset.diffScrollOffset - 1)) + } else if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) { val selectedId = withBgReset.sessions.selectedId if (selectedId != null) { val history = withBgReset.inputHistory[selectedId] ?: emptyList() @@ -227,9 +232,11 @@ object RootReducer { } } - // Input history navigation on NavigateDown in IN_SESSION + ROUTER (ARCH-HISTORY-2/3). + // Diff scrolling in APPROVAL mode when expanded. is Action.NavigateDown -> { - if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) { + if (withBgReset.displayState == DisplayState.APPROVAL && withBgReset.diffExpanded) { + withBgReset.copy(diffScrollOffset = withBgReset.diffScrollOffset + 1) + } else if (withBgReset.displayState == DisplayState.IN_SESSION && prevInputMode == InputMode.ROUTER) { val selectedId = withBgReset.sessions.selectedId if (selectedId != null) { val history = withBgReset.inputHistory[selectedId] ?: emptyList() diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt index dea9bcde..16aa5fcf 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt @@ -48,6 +48,7 @@ data class TuiState( val routerConnected: Boolean = false, val routerMessages: Map> = emptyMap(), val diffExpanded: Boolean = false, + val diffScrollOffset: Int = 0, val chatMode: ChatMode = ChatMode.CHAT, /** * True from connect until SnapshotComplete observed. Reset to true on Disconnected. diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 3752061c..8b8d06c6 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -92,6 +92,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.datetime.Clock import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject import org.slf4j.LoggerFactory import java.util.* import java.util.concurrent.* @@ -99,6 +100,7 @@ import java.util.concurrent.atomic.* import kotlin.coroutines.cancellation.CancellationException private const val MAX_TOOL_ROUNDS = 10 +private const val STAGE_COMPLETE_TOOL = "stage_complete" @SuppressWarnings( "ForbiddenComment", @@ -258,6 +260,12 @@ abstract class SessionOrchestrator( inferenceResult.response.finishReason is FinishReason.ToolCall && toolRounds < MAX_TOOL_ROUNDS ) { + // stage_complete is a meta-tool: the LLM calls it to signal the stage's goal is met. + // Skip tool dispatch — the loop exits and normal validation/transition follows + // (emitToolArtifacts + verifyProduces run on the success path after the loop). + if (inferenceResult.response.toolCalls.any { it.function.name == STAGE_COMPLETE_TOOL }) { + break + } val toolEntries = dispatchToolCalls(sessionId, stageId, inferenceResult.response.toolCalls, stageConfig) val fatalEntry = toolEntries.firstOrNull { it.content.startsWith("FATAL:") } @@ -670,7 +678,14 @@ abstract class SessionOrchestrator( parameters = tool.parametersSchema, ), ) - }, + } + ToolDefinition( + function = ToolFunction( + name = STAGE_COMPLETE_TOOL, + description = "Call this tool when the stage's goal is fully met and no further tool calls are needed. " + + "The orchestrator will proceed to the next stage.", + parameters = JsonObject(emptyMap()), + ), + ), ) // TODO: capture rendered prompt; currently contextpack.toString()