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]
/**
* 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)