From 1f6680f774ce98769a50c39e07f5a54721a5bf7f Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 29 May 2026 21:47:58 +0400 Subject: [PATCH] feat: convert router panel to conversation output log with user messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw string routerMessages with structured RouterEntry(role, content) entries. User messages are stored locally on SubmitInput (both IDLE and IN_SESSION). Router responses arrive as 'router' entries via RouterResponseMessage. Tool diffs arrive as 'tool' entries. RouterPanel renders as a styled conversation log with role prefixes: ▸ user msgs in accent, router replies in strong body, tool output in dim. Multi-line content (diffs, long responses) is split on \n for proper display. Panel title changed from 'router' to 'output' to reflect the log role. --- .../correx/apps/tui/components/RouterPanel.kt | 25 ++++++++++++++++--- .../correx/apps/tui/reducer/RootReducer.kt | 19 +++++++++++--- .../com/correx/apps/tui/state/TuiState.kt | 12 ++++++++- .../apps/tui/reducer/RootReducerTest.kt | 21 ++++++++++------ 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt index d179cc1c..133217be 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt @@ -1,5 +1,6 @@ package com.correx.apps.tui.components +import com.correx.apps.tui.state.RouterEntry import com.correx.apps.tui.state.TuiState import dev.tamboui.text.Line import dev.tamboui.text.Span @@ -12,17 +13,33 @@ import dev.tamboui.widgets.paragraph.Paragraph fun routerPanelWidget(state: TuiState): Paragraph { val lines = buildList { if (!state.routerConnected) { - add(Line.from(Span.styled("not connected — epic 14", Theme.dimStyle))) + add(Line.from(Span.styled("awaiting router response...", Theme.dimStyle))) } else { val msgs = state.routerMessages[state.sessions.selectedId].orEmpty() - for (msg in msgs) { - add(Line.from(Span.raw(msg))) + for (entry in msgs) { + val style = when (entry.role) { + "user" -> Theme.accentStyle + "router" -> Theme.fgStrongStyle + "tool" -> Theme.dimStyle + else -> Theme.fgStrongStyle + } + val prefix = when (entry.role) { + "user" -> "\u25b8 " + "router" -> " " + "tool" -> " " + else -> "" + } + val contentLines = entry.content.split("\n") + contentLines.forEachIndexed { i, lineContent -> + val text = if (i == 0) "$prefix$lineContent" else " $lineContent" + add(Line.from(Span.styled(text, style))) + } } } } val block = Block.builder() - .title("router") + .title("output") .borders(Borders.ALL) .borderType(BorderType.ROUNDED) .build() 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 70f124fa..8b8750d7 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 @@ -6,6 +6,7 @@ import com.correx.apps.tui.input.Action import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.ProviderType +import com.correx.apps.tui.state.RouterEntry import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.displayState import org.slf4j.LoggerFactory @@ -139,13 +140,23 @@ object RootReducer { val updatedHistory = (currentHistory + text).takeLast(50) withBgReset.copy( inputHistory = withBgReset.inputHistory + (selectedId to updatedHistory), + routerMessages = withBgReset.routerMessages.run { + this + (selectedId to (this[selectedId].orEmpty() + RouterEntry("user", text))) + }, ) } // Optimistic session created by SessionsReducer for IDLE chat submit. sessions.selectedId != null && prevInputMode == InputMode.ROUTER && - withBgReset.displayState == DisplayState.IDLE -> - withBgReset.copy(sessionEntered = true) + withBgReset.displayState == DisplayState.IDLE -> { + val selectedId = sessions.selectedId!! + withBgReset.copy( + sessionEntered = true, + routerMessages = withBgReset.routerMessages.run { + this + (selectedId to (this[selectedId].orEmpty() + RouterEntry("user", text))) + }, + ) + } else -> withBgReset } @@ -175,7 +186,7 @@ object RootReducer { routerConnected = true, routerMessages = withBgReset.routerMessages.run { val key = msg.sessionId.value - this + (key to (this[key].orEmpty() + msg.content)) + this + (key to (this[key].orEmpty() + RouterEntry("router", msg.content))) }, ) } @@ -183,7 +194,7 @@ object RootReducer { withBgReset.copy( routerMessages = withBgReset.routerMessages.run { val key = msg.sessionId.value - this + (key to (this[key].orEmpty() + msg.diff!!)) + this + (key to (this[key].orEmpty() + RouterEntry("tool", msg.diff!!))) }, ) } 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 808a58b3..edaf00de 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 @@ -23,6 +23,16 @@ data class TuiEventEntry( val detail: String, ) +/** + * A single entry in the router conversation log. + * @property role one of "user", "router", or "tool" + * @property content the message text + */ +data class RouterEntry( + val role: String, + val content: String, +) + data class TuiState( val connection: ConnectionState = ConnectionState(), val sessions: SessionsState = SessionsState(), @@ -46,7 +56,7 @@ data class TuiState( val currentModel: String? = null, val providerType: ProviderType = ProviderType.LOCAL, val routerConnected: Boolean = false, - val routerMessages: Map> = emptyMap(), + val routerMessages: Map> = emptyMap(), val diffExpanded: Boolean = false, val diffScrollOffset: Int = 0, val chatMode: ChatMode = ChatMode.CHAT, diff --git a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt index 6bc1dc83..20f285c0 100644 --- a/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt +++ b/apps/tui/src/test/kotlin/com/correx/apps/tui/reducer/RootReducerTest.kt @@ -9,6 +9,7 @@ import com.correx.apps.tui.state.ConnectionState import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.ProviderType +import com.correx.apps.tui.state.RouterEntry import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.TuiState @@ -391,27 +392,31 @@ class RootReducerTest { } @Test - fun `ToolCompleted with diff appends diff content to per-session routerMessages`() { + fun `ToolCompleted with diff appends diff as tool entry to per-session routerMessages`() { val initial = TuiState(snapshotPhase = false) + val diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old content\n+new content" val toolCompleted = ServerMessage.ToolCompleted( sessionId = SessionId("s1"), toolName = "file_write", outputSummary = "wrote 3 lines", occurredAt = fixedClock(), - diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old content\n+new content", + diff = diff, sequence = 1L, sessionSequence = 1L, ) val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock) val msgs = state.routerMessages["s1"] assertEquals(1, msgs?.size) - assertTrue(msgs?.first()?.contains("--- a/file.txt") == true) - assertTrue(msgs?.first()?.contains("+new content") == true) + val entry = msgs?.first() + assertEquals("tool", entry?.role) + assertTrue(entry?.content?.contains("--- a/file.txt") == true) + assertTrue(entry?.content?.contains("+new content") == true) } @Test fun `ToolCompleted without diff does not append to routerMessages`() { - val initial = TuiState(snapshotPhase = false, routerMessages = mapOf("s1" to listOf("existing msg"))) + val existing = listOf(RouterEntry("user", "existing msg")) + val initial = TuiState(snapshotPhase = false, routerMessages = mapOf("s1" to existing)) val toolCompleted = ServerMessage.ToolCompleted( sessionId = SessionId("s1"), toolName = "file_write", @@ -424,7 +429,7 @@ class RootReducerTest { val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock) val msgs = state.routerMessages["s1"] assertEquals(1, msgs?.size) - assertEquals("existing msg", msgs?.first()) + assertEquals(RouterEntry("user", "existing msg"), msgs?.first()) } @Test @@ -440,8 +445,8 @@ class RootReducerTest { ) val after1 = RootReducer.reduce(initial, Action.ServerEventReceived(msg1), fixedClock).first val after2 = RootReducer.reduce(after1, Action.ServerEventReceived(msg2), fixedClock).first - assertEquals(listOf("hello from s1"), after2.routerMessages["s1"]) - assertEquals(listOf("hello from s2"), after2.routerMessages["s2"]) + assertEquals(listOf(RouterEntry("router", "hello from s1")), after2.routerMessages["s1"]) + assertEquals(listOf(RouterEntry("router", "hello from s2")), after2.routerMessages["s2"]) } @Test