feat(tui): enrich state model and refactor input mode into TuiState

Lifts inputMode/inputBuffer out of InputState into top-level TuiState,
renames InputMode variants to ROUTER/NAVIGATE/FILTER/STEER, adds
TuiToolRecord and TuiEventEntry for per-session tool/event history,
expands SessionsReducer to track tool lifecycle and recent events, and
adds ToggleApprovalOverlay/ToggleEventOverlay/EnterSteer actions.
This commit is contained in:
2026-05-20 00:36:46 +04:00
parent bc755572bd
commit 7ac3a1ff79
12 changed files with 280 additions and 128 deletions
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %c{1} [%t] %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="com.correx" level="DEBUG" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
<Logger name="io.netty" level="WARN" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
<Logger name="org.eclipse.jetty" level="WARN" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
<Logger name="io.ktor" level="INFO" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
<Root level="INFO">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
@@ -84,8 +84,8 @@ fun main(args: Array<String>) {
val handler = EventHandler { event, _ ->
if (event is TambouiKeyEvent) {
mapKey(event, state.input.mode)
?.let { KeyResolver.resolve(it, state.input.mode, state.input.text) }
mapKey(event, state.inputMode)
?.let { KeyResolver.resolve(it, state.inputMode, state.inputBuffer) }
?.let(::dispatch)
}
true
@@ -100,7 +100,7 @@ fun main(args: Array<String>) {
private fun render(frame: Frame, state: TuiState, listState: ListState) {
val area = frame.area()
val hasInput = state.input.mode != InputMode.None
val hasInput = state.inputMode != InputMode.ROUTER
val hasApproval = state.approval.active != null
val constraints = buildList {
@@ -136,7 +136,7 @@ private fun render(frame: Frame, state: TuiState, listState: ListState) {
}
if (hasInput) {
inputBarWidget(state.input)?.let { frame.renderWidget(it, rects[idx]) }
inputBarWidget(state.inputMode, state.inputBuffer)?.let { frame.renderWidget(it, rects[idx]) }
idx++
}
@@ -1,7 +1,6 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.InputState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
@@ -11,17 +10,17 @@ import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
fun inputBarWidget(input: InputState): Paragraph? {
if (input.mode == InputMode.None) return null
val (promptText, promptStyle) = when (input.mode) {
InputMode.WorkflowId -> "new session > " to Style.create().cyan()
InputMode.Filter -> "filter > " to Style.create().yellow()
InputMode.SteeringNote -> "steer > " to Style.create().magenta()
else -> "" to Style.create()
fun inputBarWidget(inputMode: InputMode, inputBuffer: String): Paragraph? {
if (inputMode == InputMode.ROUTER) return null
val (promptText, promptStyle) = when (inputMode) {
InputMode.ROUTER -> "" to Style.create()
InputMode.NAVIGATE -> "" to Style.create()
InputMode.FILTER -> "filter > " to Style.create().yellow()
InputMode.STEER -> "steer > " to Style.create().magenta()
}
val line = Line.from(
Span.styled(promptText, promptStyle),
Span.raw("${input.text}_")
Span.raw("${inputBuffer}_")
)
val block = Block.builder()
.title("input")
@@ -20,4 +20,7 @@ sealed interface Action {
data object Connected : Action
data object Disconnected : Action
data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long) : Action
data object ToggleApprovalOverlay : Action
data object ToggleEventOverlay : Action
data object EnterSteer : Action
}
@@ -5,11 +5,12 @@ import com.correx.apps.tui.state.InputMode
object KeyResolver {
fun resolve(key: KeyEvent, mode: InputMode, inputText: String): Action? = when (mode) {
InputMode.None -> resolveCommandMode(key)
else -> resolveInputMode(key, inputText)
InputMode.ROUTER -> resolveRouterMode(key)
InputMode.NAVIGATE -> resolveNavigateMode(key)
InputMode.FILTER, InputMode.STEER -> resolveTextMode(key, inputText)
}
private fun resolveCommandMode(key: KeyEvent): Action? = when (key) {
private fun resolveRouterMode(key: KeyEvent): Action? = when (key) {
KeyEvent.Quit -> Action.Quit
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
KeyEvent.Cancel -> Action.CancelSelectedSession
@@ -22,7 +23,14 @@ object KeyResolver {
else -> null
}
private fun resolveInputMode(key: KeyEvent, inputText: String): Action? = when (key) {
private fun resolveNavigateMode(key: KeyEvent): Action? = when (key) {
KeyEvent.NavUp -> Action.NavigateUp
KeyEvent.NavDown -> Action.NavigateDown
KeyEvent.Escape -> Action.CancelInput
else -> null
}
private fun resolveTextMode(key: KeyEvent, inputText: String): Action? = when (key) {
KeyEvent.Escape -> Action.CancelInput
KeyEvent.Backspace -> Action.Backspace
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
@@ -19,7 +19,7 @@ fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? {
KeyCode.CHAR -> {
val ch = event.character()
if (ch.isISOControl()) return null
if (mode != InputMode.None) return KeyEvent.CharInput(ch)
if (mode != InputMode.ROUTER) return KeyEvent.CharInput(ch)
when (ch) {
'q' -> KeyEvent.Quit
'n' -> KeyEvent.NewSession
@@ -47,7 +47,7 @@ object ApprovalReducer {
approval to emptyList()
}
}
is Action.SubmitInput -> if (inputMode == InputMode.SteeringNote) {
is Action.SubmitInput -> if (inputMode == InputMode.STEER) {
ApprovalState(active = null) to emptyList()
} else {
approval to emptyList()
@@ -3,58 +3,58 @@ package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.InputState
import com.correx.apps.tui.state.TuiState
import com.correx.core.events.types.ApprovalRequestId
object InputReducer {
@Suppress("CyclomaticComplexMethod")
fun reduce(
input: InputState,
activeApproval: ApprovalInfo?,
state: TuiState,
action: Action,
): Pair<InputState, List<Effect>> = when (action) {
is Action.OpenNewSessionPrompt -> InputState(InputMode.WorkflowId, "") to emptyList()
is Action.OpenSteeringPrompt -> if (activeApproval != null) {
InputState(InputMode.SteeringNote, "") to emptyList()
): Pair<TuiState, List<Effect>> = when (action) {
is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
is Action.OpenSteeringPrompt -> if (state.approval.active != null) {
state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
} else {
input to emptyList()
state to emptyList()
}
is Action.OpenFilter -> InputState(InputMode.Filter, "") to emptyList()
is Action.AppendChar -> input.copy(text = input.text + action.ch) to emptyList()
is Action.Backspace -> input.copy(text = input.text.dropLast(1)) to emptyList()
is Action.CancelInput -> InputState(InputMode.None, "") to emptyList()
is Action.SubmitInput -> when (input.mode) {
InputMode.WorkflowId -> {
val text = input.text.trim()
is Action.EnterSteer -> state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
is Action.OpenFilter -> state.copy(inputMode = InputMode.FILTER, inputBuffer = "") to emptyList()
is Action.AppendChar -> state.copy(inputBuffer = state.inputBuffer + action.ch) to emptyList()
is Action.Backspace -> state.copy(inputBuffer = state.inputBuffer.dropLast(1)) to emptyList()
is Action.CancelInput -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
is Action.SubmitInput -> when (state.inputMode) {
InputMode.ROUTER -> {
val text = state.inputBuffer.trim()
if (text.isNotBlank()) {
InputState(InputMode.None, "") to listOf(
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to listOf(
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)),
)
} else {
InputState(InputMode.None, "") to emptyList()
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
}
}
InputMode.SteeringNote -> {
val text = input.text.trim()
if (activeApproval != null && text.isNotBlank()) {
InputState(InputMode.None, "") to listOf(
InputMode.STEER -> {
val text = state.inputBuffer.trim()
val active = state.approval.active
if (active != null && text.isNotBlank()) {
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to listOf(
Effect.SendWs(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(activeApproval.requestId),
requestId = ApprovalRequestId(active.requestId),
decision = ApprovalDecision.STEER,
steeringNote = text,
),
),
)
} else {
InputState(InputMode.None, "") to emptyList()
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
}
}
InputMode.Filter -> InputState(InputMode.None, "") to emptyList()
InputMode.None -> input to emptyList()
InputMode.FILTER -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
InputMode.NAVIGATE -> state to emptyList()
}
else -> input to emptyList()
else -> state to emptyList()
}
}
@@ -10,17 +10,30 @@ object RootReducer {
clock: () -> Long = System::currentTimeMillis,
): Pair<TuiState, List<Effect>> {
val quitEffects: List<Effect> = if (action is Action.Quit) listOf(Effect.Quit) else emptyList()
val (input, ie) = InputReducer.reduce(state.input, state.approval.active, action)
val (sessions, se) = SessionsReducer.reduce(state.sessions, state.input.mode, state.input.text, action, clock)
val (approval, ae) = ApprovalReducer.reduce(state.approval, state.input.mode, action)
val (connection, ce) = ConnectionReducer.reduce(state.connection, action)
val (provider, pe) = ProviderReducer.reduce(state.provider, action)
return state.copy(
input = input,
val prevInputMode = state.inputMode
val prevInputBuffer = state.inputBuffer
val (afterInput, ie) = InputReducer.reduce(state, action)
val (sessions, se) = SessionsReducer.reduce(afterInput.sessions, prevInputMode, prevInputBuffer, action, clock)
val (approval, ae) = ApprovalReducer.reduce(afterInput.approval, prevInputMode, action)
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
val withSubReducers = afterInput.copy(
sessions = sessions,
approval = approval,
connection = connection,
provider = provider,
) to (quitEffects + ie + se + ae + ce + pe)
)
val final = when (action) {
is Action.ToggleApprovalOverlay -> withSubReducers.copy(
approvalOverlayVisible = !withSubReducers.approvalOverlayVisible,
eventOverlayVisible = false,
)
is Action.ToggleEventOverlay -> withSubReducers.copy(
eventOverlayVisible = !withSubReducers.eventOverlayVisible,
approvalOverlayVisible = false,
)
else -> withSubReducers
}
return final to (quitEffects + ie + se + ae + ce + pe)
}
}
@@ -7,9 +7,17 @@ import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.ToolDisplayStatus
import com.correx.apps.tui.state.TuiEventEntry
import com.correx.apps.tui.state.TuiToolRecord
import com.correx.core.events.types.SessionId
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
object SessionsReducer {
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneOffset.UTC)
fun reduce(
sessions: SessionsState,
inputMode: InputMode,
@@ -17,15 +25,15 @@ object SessionsReducer {
action: Action,
clock: () -> Long = System::currentTimeMillis,
): Pair<SessionsState, List<Effect>> = when (action) {
is Action.NavigateUp -> navigateUp(sessions) to emptyList()
is Action.NavigateDown -> navigateDown(sessions) to emptyList()
is Action.SubmitInput -> if (inputMode == InputMode.Filter) {
is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) navigateUp(sessions) to emptyList() else sessions to emptyList()
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) navigateDown(sessions) to emptyList() else sessions to emptyList()
is Action.SubmitInput -> if (inputMode == InputMode.FILTER) {
sessions.copy(filter = inputText) to emptyList()
} else {
sessions to emptyList()
}
is Action.CancelInput -> if (inputMode == InputMode.Filter) {
is Action.CancelInput -> if (inputMode == InputMode.FILTER) {
sessions.copy(filter = "") to emptyList()
} else {
sessions to emptyList()
@@ -60,6 +68,9 @@ object SessionsReducer {
return sessions.copy(selectedId = list[newIdx].id)
}
private fun formatTime(epochMs: Long): String =
timeFormatter.format(Instant.ofEpochMilli(epochMs))
@Suppress("CyclomaticComplexMethod")
private fun applyServerMessage(
sessions: SessionsState,
@@ -71,6 +82,7 @@ object SessionsReducer {
id = msg.sessionId.value,
status = "ACTIVE",
workflowId = msg.workflowId,
name = msg.workflowId,
lastEventAt = clock(),
currentStage = null,
lastOutput = null,
@@ -104,38 +116,145 @@ object SessionsReducer {
clock,
) to listOf(Effect.DisconnectSession)
is ServerMessage.StageStarted -> sessions.copy(
is ServerMessage.StageStarted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(currentStage = msg.stageId.value, lastEventAt = clock())
val entry = TuiEventEntry(formatTime(now), "StageStarted", msg.stageId.value)
s.copy(
currentStage = msg.stageId.value,
currentStageId = msg.stageId.value,
tools = emptyList(),
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.StageCompleted -> sessions.copy(
is ServerMessage.StageCompleted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageCompleted", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.StageFailed -> sessions.copy(
is ServerMessage.StageFailed -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(currentStage = null, lastEventAt = clock()) else s
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageFailed", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolStarted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val record = TuiToolRecord(msg.toolName, msg.tier.level, ToolDisplayStatus.STARTED, null)
s.copy(
tools = (s.tools + record).takeLast(8),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
is ServerMessage.ToolCompleted -> sessions.copy(
is ServerMessage.ToolCompleted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(lastOutput = "${msg.toolName}: ${msg.outputSummary}", lastEventAt = clock())
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.COMPLETED)
} else {
t
}
}
val entry = TuiEventEntry(formatTime(now), "ToolCompleted", msg.toolName)
s.copy(
tools = updatedTools,
lastOutput = "${msg.toolName}: ${msg.outputSummary}",
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolFailed -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
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.FAILED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolRejected -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
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.REJECTED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
else -> sessions to emptyList()
}
@@ -144,19 +263,24 @@ object SessionsReducer {
sessions: SessionsState,
msg: ServerMessage.InferenceCompleted,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> = sessions.copy(
): Pair<SessionsState, List<Effect>> {
val now = clock()
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "InferenceCompleted", msg.stageId.value)
s.copy(
lastOutput = msg.outputSummary,
lastResponseText = msg.responseText.takeIf { it.isNotEmpty() },
lastEventAt = clock(),
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun touchSession(
sessions: SessionsState,
@@ -1,6 +1,6 @@
package com.correx.apps.tui.state
data class InputState(
val mode: InputMode = InputMode.None,
val mode: InputMode = InputMode.ROUTER,
val text: String = "",
)
@@ -1,6 +1,23 @@
package com.correx.apps.tui.state
enum class InputMode { None, WorkflowId, SteeringNote, Filter }
enum class InputMode { ROUTER, NAVIGATE, FILTER, STEER }
enum class ToolDisplayStatus { REQUESTED, STARTED, COMPLETED, FAILED, REJECTED }
enum class ProviderType { LOCAL, REMOTE }
data class TuiToolRecord(
val name: String,
val tier: Int,
val status: ToolDisplayStatus,
val argsPreview: String?,
)
data class TuiEventEntry(
val timestamp: String,
val type: String,
val detail: String,
)
data class TuiState(
val connection: ConnectionState = ConnectionState(),
@@ -8,16 +25,29 @@ data class TuiState(
val input: InputState = InputState(),
val approval: ApprovalState = ApprovalState(),
val provider: ProviderState = ProviderState(),
val inputMode: InputMode = InputMode.ROUTER,
val inputBuffer: String = "",
val approvalOverlayVisible: Boolean = false,
val eventOverlayVisible: Boolean = false,
val currentModel: String? = null,
val providerType: ProviderType = ProviderType.LOCAL,
val routerConnected: Boolean = false,
val routerMessages: List<String> = emptyList(),
)
data class SessionSummary(
val id: String,
val status: String,
val workflowId: String,
val name: String = "",
val lastEventAt: Long,
val currentStage: String?,
val lastOutput: String?,
val currentStage: String? = null,
val currentStageId: String? = null,
val nextStageId: String? = null,
val lastOutput: String? = null,
val lastResponseText: String? = null,
val tools: List<TuiToolRecord> = emptyList(),
val recentEvents: List<TuiEventEntry> = emptyList(),
)
data class ApprovalInfo(