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
|
package com.correx.apps.tui.components
|
||||||
|
|
||||||
|
import com.correx.apps.tui.state.RouterEntry
|
||||||
import com.correx.apps.tui.state.TuiState
|
import com.correx.apps.tui.state.TuiState
|
||||||
import dev.tamboui.text.Line
|
import dev.tamboui.text.Line
|
||||||
import dev.tamboui.text.Span
|
import dev.tamboui.text.Span
|
||||||
@@ -12,17 +13,33 @@ import dev.tamboui.widgets.paragraph.Paragraph
|
|||||||
fun routerPanelWidget(state: TuiState): Paragraph {
|
fun routerPanelWidget(state: TuiState): Paragraph {
|
||||||
val lines = buildList<Line> {
|
val lines = buildList<Line> {
|
||||||
if (!state.routerConnected) {
|
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 {
|
} else {
|
||||||
val msgs = state.routerMessages[state.sessions.selectedId].orEmpty()
|
val msgs = state.routerMessages[state.sessions.selectedId].orEmpty()
|
||||||
for (msg in msgs) {
|
for (entry in msgs) {
|
||||||
add(Line.from(Span.raw(msg)))
|
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()
|
val block = Block.builder()
|
||||||
.title("router")
|
.title("output")
|
||||||
.borders(Borders.ALL)
|
.borders(Borders.ALL)
|
||||||
.borderType(BorderType.ROUNDED)
|
.borderType(BorderType.ROUNDED)
|
||||||
.build()
|
.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.DisplayState
|
||||||
import com.correx.apps.tui.state.InputMode
|
import com.correx.apps.tui.state.InputMode
|
||||||
import com.correx.apps.tui.state.ProviderType
|
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.TuiState
|
||||||
import com.correx.apps.tui.state.displayState
|
import com.correx.apps.tui.state.displayState
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
@@ -139,13 +140,23 @@ object RootReducer {
|
|||||||
val updatedHistory = (currentHistory + text).takeLast(50)
|
val updatedHistory = (currentHistory + text).takeLast(50)
|
||||||
withBgReset.copy(
|
withBgReset.copy(
|
||||||
inputHistory = withBgReset.inputHistory + (selectedId to updatedHistory),
|
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.
|
// Optimistic session created by SessionsReducer for IDLE chat submit.
|
||||||
sessions.selectedId != null && prevInputMode == InputMode.ROUTER &&
|
sessions.selectedId != null && prevInputMode == InputMode.ROUTER &&
|
||||||
withBgReset.displayState == DisplayState.IDLE ->
|
withBgReset.displayState == DisplayState.IDLE -> {
|
||||||
withBgReset.copy(sessionEntered = true)
|
val selectedId = sessions.selectedId!!
|
||||||
|
withBgReset.copy(
|
||||||
|
sessionEntered = true,
|
||||||
|
routerMessages = withBgReset.routerMessages.run {
|
||||||
|
this + (selectedId to (this[selectedId].orEmpty() + RouterEntry("user", text)))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> withBgReset
|
else -> withBgReset
|
||||||
}
|
}
|
||||||
@@ -175,7 +186,7 @@ object RootReducer {
|
|||||||
routerConnected = true,
|
routerConnected = true,
|
||||||
routerMessages = withBgReset.routerMessages.run {
|
routerMessages = withBgReset.routerMessages.run {
|
||||||
val key = msg.sessionId.value
|
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(
|
withBgReset.copy(
|
||||||
routerMessages = withBgReset.routerMessages.run {
|
routerMessages = withBgReset.routerMessages.run {
|
||||||
val key = msg.sessionId.value
|
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,
|
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(
|
data class TuiState(
|
||||||
val connection: ConnectionState = ConnectionState(),
|
val connection: ConnectionState = ConnectionState(),
|
||||||
val sessions: SessionsState = SessionsState(),
|
val sessions: SessionsState = SessionsState(),
|
||||||
@@ -46,7 +56,7 @@ data class TuiState(
|
|||||||
val currentModel: String? = null,
|
val currentModel: String? = null,
|
||||||
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<RouterEntry>> = emptyMap(),
|
||||||
val diffExpanded: Boolean = false,
|
val diffExpanded: Boolean = false,
|
||||||
val diffScrollOffset: Int = 0,
|
val diffScrollOffset: Int = 0,
|
||||||
val chatMode: ChatMode = ChatMode.CHAT,
|
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.DisplayState
|
||||||
import com.correx.apps.tui.state.InputMode
|
import com.correx.apps.tui.state.InputMode
|
||||||
import com.correx.apps.tui.state.ProviderType
|
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.SessionSummary
|
||||||
import com.correx.apps.tui.state.SessionsState
|
import com.correx.apps.tui.state.SessionsState
|
||||||
import com.correx.apps.tui.state.TuiState
|
import com.correx.apps.tui.state.TuiState
|
||||||
@@ -391,27 +392,31 @@ class RootReducerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 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(
|
val toolCompleted = ServerMessage.ToolCompleted(
|
||||||
sessionId = SessionId("s1"),
|
sessionId = SessionId("s1"),
|
||||||
toolName = "file_write",
|
toolName = "file_write",
|
||||||
outputSummary = "wrote 3 lines",
|
outputSummary = "wrote 3 lines",
|
||||||
occurredAt = fixedClock(),
|
occurredAt = fixedClock(),
|
||||||
diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old content\n+new content",
|
diff = diff,
|
||||||
sequence = 1L,
|
sequence = 1L,
|
||||||
sessionSequence = 1L,
|
sessionSequence = 1L,
|
||||||
)
|
)
|
||||||
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
|
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
|
||||||
val msgs = state.routerMessages["s1"]
|
val msgs = state.routerMessages["s1"]
|
||||||
assertEquals(1, msgs?.size)
|
assertEquals(1, msgs?.size)
|
||||||
assertTrue(msgs?.first()?.contains("--- a/file.txt") == true)
|
val entry = msgs?.first()
|
||||||
assertTrue(msgs?.first()?.contains("+new content") == true)
|
assertEquals("tool", entry?.role)
|
||||||
|
assertTrue(entry?.content?.contains("--- a/file.txt") == true)
|
||||||
|
assertTrue(entry?.content?.contains("+new content") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `ToolCompleted without diff does not append to routerMessages`() {
|
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(
|
val toolCompleted = ServerMessage.ToolCompleted(
|
||||||
sessionId = SessionId("s1"),
|
sessionId = SessionId("s1"),
|
||||||
toolName = "file_write",
|
toolName = "file_write",
|
||||||
@@ -424,7 +429,7 @@ class RootReducerTest {
|
|||||||
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
|
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
|
||||||
val msgs = state.routerMessages["s1"]
|
val msgs = state.routerMessages["s1"]
|
||||||
assertEquals(1, msgs?.size)
|
assertEquals(1, msgs?.size)
|
||||||
assertEquals("existing msg", msgs?.first())
|
assertEquals(RouterEntry("user", "existing msg"), msgs?.first())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -440,8 +445,8 @@ class RootReducerTest {
|
|||||||
)
|
)
|
||||||
val after1 = RootReducer.reduce(initial, Action.ServerEventReceived(msg1), fixedClock).first
|
val after1 = RootReducer.reduce(initial, Action.ServerEventReceived(msg1), fixedClock).first
|
||||||
val after2 = RootReducer.reduce(after1, Action.ServerEventReceived(msg2), fixedClock).first
|
val after2 = RootReducer.reduce(after1, Action.ServerEventReceived(msg2), fixedClock).first
|
||||||
assertEquals(listOf("hello from s1"), after2.routerMessages["s1"])
|
assertEquals(listOf(RouterEntry("router", "hello from s1")), after2.routerMessages["s1"])
|
||||||
assertEquals(listOf("hello from s2"), after2.routerMessages["s2"])
|
assertEquals(listOf(RouterEntry("router", "hello from s2")), after2.routerMessages["s2"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user