feat: convert router panel to conversation output log with user messages

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.
This commit is contained in:
2026-05-29 21:47:58 +04:00
parent 1cad2bbde1
commit 1f6680f774
4 changed files with 60 additions and 17 deletions
@@ -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<Line> {
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()
@@ -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!!)))
},
)
}
@@ -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<String, List<String>> = emptyMap(),
val routerMessages: Map<String, List<RouterEntry>> = emptyMap(),
val diffExpanded: Boolean = false,
val diffScrollOffset: Int = 0,
val chatMode: ChatMode = ChatMode.CHAT,
@@ -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