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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user