feat: wire ChatMode.STEERING toggle in TUI input bar

- Add ChatMode.STEERING toggle via ctrl+s keybinding
- Add KeyEvent.ToggleSteeringMode, Action.CycleChatMode
- TambouiKeyMapper: map ctrl+s to ToggleSteeringMode
- KeyResolver: route ToggleSteeringMode to CycleChatMode (global)
- InputReducer: toggle chatMode between CHAT and STEERING
- TuiState: add chatMode field (default CHAT)
- SessionsReducer: use ctx.chatMode instead of hardcoded CHAT
- RootReducer: pass afterInput.chatMode to SessionsReducerContext
- InputBar: show blue 'chat' or magenta 'steer' label in status line

Activation: ctrl+s toggles mode, 'steer' label appears, submitted
ChatInput uses ChatMode.STEERING → router emits SteeringNoteAddedEvent
→ context builder folds into next stage's context pack
This commit is contained in:
2026-05-29 02:27:23 +04:00
parent 3981d8443d
commit fb3ab9b344
10 changed files with 45 additions and 8 deletions
@@ -20,4 +20,5 @@ sealed class KeyEvent {
object CursorLeft : KeyEvent() object CursorLeft : KeyEvent()
object CursorRight : KeyEvent() object CursorRight : KeyEvent()
object ToggleWorkflows : KeyEvent() object ToggleWorkflows : KeyEvent()
object ToggleSteeringMode : KeyEvent()
} }
@@ -18,6 +18,11 @@ private fun promptStyle(inputMode: InputMode): Style = when (inputMode) {
InputMode.FILTER -> Style.create().yellow() InputMode.FILTER -> Style.create().yellow()
} }
private fun chatModeLabel(state: TuiState): Span = when (state.chatMode) {
com.correx.core.router.ChatMode.CHAT -> Span.styled("chat", Style.create().blue())
com.correx.core.router.ChatMode.STEERING -> Span.styled("steer", Style.create().magenta())
}
private fun cursorLine(buffer: String, cursor: Int, inputMode: InputMode): Line { private fun cursorLine(buffer: String, cursor: Int, inputMode: InputMode): Line {
val prompt = Span.styled("", promptStyle(inputMode)) val prompt = Span.styled("", promptStyle(inputMode))
if (buffer.isEmpty()) return Line.from(prompt, Span.raw("")) if (buffer.isEmpty()) return Line.from(prompt, Span.raw(""))
@@ -77,7 +82,9 @@ private fun createRowsForIdleRouter(
} }
return row1 to Line.from( return row1 to Line.from(
Span.styled(sessionName, Style.create().cyan()), Span.styled(sessionName, Style.create().cyan()),
Span.styled(" · router ", dimStyle), Span.styled(" · ", dimStyle),
chatModeLabel(state),
Span.styled(" ", dimStyle),
Span.styled("tab", Style.create().blue()), Span.styled("tab", Style.create().blue()),
Span.styled(" filter ", dimStyle), Span.styled(" filter ", dimStyle),
Span.styled("ctrl+n", Style.create().blue()), Span.styled("ctrl+n", Style.create().blue()),
@@ -101,7 +108,9 @@ private fun createRowsForInSessionRouter(
} }
return row1 to Line.from( return row1 to Line.from(
Span.styled(sessionName, Style.create().cyan()), Span.styled(sessionName, Style.create().cyan()),
Span.styled(" · router ", dimStyle), Span.styled(" · ", dimStyle),
chatModeLabel(state),
Span.styled(" ", dimStyle),
Span.styled("tab", Style.create().blue()), Span.styled("tab", Style.create().blue()),
Span.styled(" filter ", dimStyle), Span.styled(" filter ", dimStyle),
Span.styled("↑↓", Style.create().blue()), Span.styled("↑↓", Style.create().blue()),
@@ -27,5 +27,6 @@ sealed interface Action {
data object CursorLeft : Action data object CursorLeft : Action
data object CursorRight : Action data object CursorRight : Action
data object ToggleWorkflows : Action data object ToggleWorkflows : Action
data object CycleChatMode : Action
data object NoOp : Action data object NoOp : Action
} }
@@ -17,6 +17,7 @@ object KeyResolver {
KeyEvent.Quit -> Action.Quit KeyEvent.Quit -> Action.Quit
KeyEvent.Approve -> Action.ApproveActive KeyEvent.Approve -> Action.ApproveActive
KeyEvent.Reject -> Action.RejectActive KeyEvent.Reject -> Action.RejectActive
KeyEvent.ToggleSteeringMode -> Action.CycleChatMode
else -> null else -> null
} }
if (global != null) return global if (global != null) return global
@@ -24,6 +24,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
event.isChar('e') -> KeyEvent.ToggleEventStrip event.isChar('e') -> KeyEvent.ToggleEventStrip
event.isChar('h') -> KeyEvent.ShowPendingApproval event.isChar('h') -> KeyEvent.ShowPendingApproval
event.isChar('w') -> KeyEvent.ToggleWorkflows event.isChar('w') -> KeyEvent.ToggleWorkflows
event.isChar('s') -> KeyEvent.ToggleSteeringMode
else -> null else -> null
} }
} }
@@ -7,6 +7,7 @@ import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.selectedPendingApproval import com.correx.apps.tui.state.selectedPendingApproval
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.router.ChatMode
object InputReducer { object InputReducer {
@Suppress("CyclomaticComplexMethod") @Suppress("CyclomaticComplexMethod")
@@ -21,6 +22,13 @@ object InputReducer {
}, },
) to emptyList() ) to emptyList()
is Action.CycleChatMode -> state.copy(
chatMode = when (state.chatMode) {
ChatMode.CHAT -> ChatMode.STEERING
ChatMode.STEERING -> ChatMode.CHAT
},
) to emptyList()
is Action.CursorLeft -> state.copy( is Action.CursorLeft -> state.copy(
inputCursor = (state.inputCursor - 1).coerceAtLeast(0), inputCursor = (state.inputCursor - 1).coerceAtLeast(0),
) to emptyList() ) to emptyList()
@@ -69,6 +69,7 @@ object RootReducer {
inputText = prevInputBuffer, inputText = prevInputBuffer,
action = action, action = action,
clock = clock, clock = clock,
chatMode = afterInput.chatMode,
), ),
) )
log.debug("connection state before reducers={}", state.connection) log.debug("connection state before reducers={}", state.connection)
@@ -29,6 +29,7 @@ data class SessionsReducerContext(
val inputText: String, val inputText: String,
val action: Action, val action: Action,
val clock: () -> Long = System::currentTimeMillis, val clock: () -> Long = System::currentTimeMillis,
val chatMode: ChatMode = ChatMode.CHAT,
) )
object SessionsReducer { object SessionsReducer {
@@ -89,7 +90,7 @@ object SessionsReducer {
ClientMessage.ChatInput( ClientMessage.ChatInput(
sessionId = SessionId(sid), sessionId = SessionId(sid),
text = ctx.inputText, text = ctx.inputText,
mode = ChatMode.CHAT, mode = ctx.chatMode,
), ),
), ),
) )
@@ -2,6 +2,7 @@ package com.correx.apps.tui.state
import com.correx.apps.server.protocol.ApprovalDecision import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.router.ChatMode
enum class InputMode { ROUTER, FILTER } enum class InputMode { ROUTER, FILTER }
@@ -46,6 +47,7 @@ data class TuiState(
val providerType: ProviderType = ProviderType.LOCAL, val providerType: ProviderType = ProviderType.LOCAL,
val routerConnected: Boolean = false, val routerConnected: Boolean = false,
val routerMessages: Map<String, List<String>> = emptyMap(), val routerMessages: Map<String, List<String>> = emptyMap(),
val chatMode: ChatMode = ChatMode.CHAT,
/** /**
* True from connect until SnapshotComplete observed. Reset to true on Disconnected. * True from connect until SnapshotComplete observed. Reset to true on Disconnected.
* When true, event-bearing messages are buffered in [pendingEvents]; snapshots are applied immediately. * When true, event-bearing messages are buffered in [pendingEvents]; snapshots are applied immediately.
@@ -549,17 +549,29 @@ abstract class SessionOrchestrator(
private suspend fun buildSteeringNoteEntries(sessionId: SessionId): List<ContextEntry> { private suspend fun buildSteeringNoteEntries(sessionId: SessionId): List<ContextEntry> {
val events = eventStore.read(sessionId) val events = eventStore.read(sessionId)
return events.mapNotNull { event -> return events.mapNotNull { event ->
(event.payload as? SteeringNoteAddedEvent)?.let { steering -> when (val p = event.payload) {
is SteeringNoteAddedEvent -> ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
content = p.content,
sourceType = "steeringNote",
sourceId = p.stageId?.value ?: sessionId.value,
tokenEstimate = estimateTokens(p.content),
role = EntryRole.SYSTEM,
)
is ApprovalDecisionResolvedEvent -> p.userSteering?.let { steering ->
ContextEntry( ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()), id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2, layer = ContextLayer.L2,
content = steering.content, content = steering.text,
sourceType = "steeringNote", sourceType = "steeringNote",
sourceId = steering.stageId?.value ?: sessionId.value, sourceId = steering.sessionId.value,
tokenEstimate = estimateTokens(steering.content), tokenEstimate = estimateTokens(steering.text),
role = EntryRole.SYSTEM, role = EntryRole.USER,
) )
} }
else -> null
}
} }
} }