feat: soft-rounded TUI layout with blue accent theme — all 9 phases

Complete TUI visual redesign: Theme.kt with full palette + 10 styles,
3-row grid layout with 1.7:1 horizontal split, redesigned StatusBar
with context meter, EventStreamPanel, FooterBar with contextual
keybinds, WelcomePanel for IDLE state, InputBar as left panel footer.
All existing components updated to use Theme colors. RouterPanel keeps
'not connected — epic 14' placeholder.
This commit is contained in:
2026-05-29 20:53:17 +04:00
parent 35d5c24eae
commit 955f1a6987
13 changed files with 646 additions and 235 deletions
@@ -1,12 +1,14 @@
package com.correx.apps.tui
import com.correx.apps.tui.components.approvalSurfaceWidget
import com.correx.apps.tui.components.eventHistoryStripWidget
import com.correx.apps.tui.components.eventStreamPanelWidget
import com.correx.apps.tui.components.filteredSessions
import com.correx.apps.tui.components.footerBarWidget
import com.correx.apps.tui.components.inputBarWidget
import com.correx.apps.tui.components.routerPanelWidget
import com.correx.apps.tui.components.sessionListWidget
import com.correx.apps.tui.components.statusBarWidget
import com.correx.apps.tui.components.welcomePanelWidget
import com.correx.apps.tui.components.workflowListWidget
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.input.KeyResolver
@@ -36,11 +38,11 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private const val DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 1
private const val EVENT_STRIP_HEIGHT = 6
private const val WORKFLOW_HEIGHT = 6
private const val INPUT_HEIGHT = 4
private const val FOOTER_HEIGHT = 1
private const val MAX_VISIBLE_SESSIONS = 7
private const val SESSION_BORDER_PADDING = 2
private const val WORKFLOW_HEIGHT = 6
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
@@ -66,18 +68,11 @@ fun main(args: Array<String>) {
state = next
if (effects.isNotEmpty()) {
effectScope.launch {
// Sequential: each effect awaits the previous before starting.
// Ordering invariant I-E1: matches RootReducer concatenation order.
// Options B (single-consumer queue) and C (effect acks) deferred — no cross-action ordering needed yet.
dispatchAll(dispatcher, effects)
}
}
}
// IMPORTANT: ws.messages and ws.connection are single-consumer cold flows
// (Channel.UNLIMITED.receiveAsFlow). Do not add a second collector for either —
// events would be split across collectors and dropped from the dispatcher's view.
// If a second consumer is needed (e.g. metrics), share via a downstream broadcast.
effectScope.launch {
ws.messages.collect { msg ->
runner.runOnRenderThread { dispatch(Action.ServerEventReceived(msg)) }
@@ -128,85 +123,126 @@ private fun render(frame: Frame, state: TuiState) {
}
}
/**
* 3-row grid: status bar | main area (horizontal split 1.7:1) | footer bar
* Left panel: session list [+ workflow list] + input bar footer
* Right panel: welcome panel
*/
private fun renderIdleLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val vertRects = Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.fill(), // main area
Constraint.length(FOOTER_HEIGHT),
)
.split(area)
val mainRects = Layout.horizontal()
.constraints(Constraint.fill(17), Constraint.fill(10)) // 1.7:1
.split(vertRects[1])
frame.renderWidget(statusBarWidget(state), vertRects[0])
renderLeftPanelWithInputBar(frame, state, mainRects[0]) { leftRect ->
renderIdleLeftContent(frame, state, leftRect)
}
frame.renderWidget(welcomePanelWidget(state), mainRects[1])
frame.renderWidget(footerBarWidget(state), vertRects[2])
}
/**
* 3-row grid: status bar | main area (horizontal split 1.7:1) | footer bar
* Left panel: router conversation + input bar footer
* Right panel: event stream
*/
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val vertRects = Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.fill(), // main area
Constraint.length(FOOTER_HEIGHT),
)
.split(area)
val mainRects = Layout.horizontal()
.constraints(Constraint.fill(17), Constraint.fill(10)) // 1.7:1
.split(vertRects[1])
frame.renderWidget(statusBarWidget(state), vertRects[0])
renderLeftPanelWithInputBar(frame, state, mainRects[0]) { leftRect ->
frame.renderWidget(routerPanelWidget(state), leftRect)
}
frame.renderWidget(eventStreamPanelWidget(state), mainRects[1])
frame.renderWidget(footerBarWidget(state), vertRects[2])
}
/**
* 3-row grid: status bar | main area (full width, no split) | footer bar
* Main area shows the approval surface.
*/
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val vertRects = Layout.vertical()
.constraints(
Constraint.length(STATUS_HEIGHT),
Constraint.fill(), // main area
Constraint.length(FOOTER_HEIGHT),
)
.split(area)
frame.renderWidget(statusBarWidget(state), vertRects[0])
frame.renderWidget(approvalSurfaceWidget(state), vertRects[1])
frame.renderWidget(footerBarWidget(state), vertRects[2])
}
/**
* Renders the left panel content in the upper portion of [leftRect], with the
* InputBar pinned to the bottom. The [contentRenderer] draws the primary content
* (session list for IDLE, router panel for IN_SESSION) into the top sub-rect.
*/
private fun renderLeftPanelWithInputBar(
frame: Frame,
state: TuiState,
leftRect: dev.tamboui.layout.Rect,
contentRenderer: (dev.tamboui.layout.Rect) -> Unit,
) {
val panelRects = Layout.vertical()
.constraints(
Constraint.fill(), // content area
Constraint.length(INPUT_HEIGHT), // input bar footer
)
.split(leftRect)
contentRenderer(panelRects[0])
frame.renderWidget(inputBarWidget(state, state.displayState), panelRects[1])
}
/**
* Renders the session list and (optionally) the workflow picker in the IDLE left panel.
*/
private fun renderIdleLeftContent(frame: Frame, state: TuiState, rect: dev.tamboui.layout.Rect) {
val sessionCount = filteredSessions(state.sessions).size
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
val hasWorkflows = state.sessions.workflows.isNotEmpty()
val showWorkflows = hasWorkflows && (state.sessions.workflowsVisible || state.sessions.sessions.isEmpty())
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
Constraint.length(sessionListHeight),
)
if (showWorkflows) {
constraints.add(Constraint.length(WORKFLOW_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
val vertRects = Layout.vertical()
val contentRects = Layout.vertical()
.constraints(constraints)
.split(area)
.split(rect)
var rectIdx = 0
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
frame.renderWidget(sessionListWidget(state), vertRects[rectIdx++])
frame.renderWidget(sessionListWidget(state), contentRects[rectIdx++])
if (showWorkflows) {
frame.renderWidget(workflowListWidget(state), vertRects[rectIdx++])
frame.renderWidget(workflowListWidget(state), contentRects[rectIdx++])
}
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[rectIdx])
}
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val sessionCount = filteredSessions(state.sessions).size
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
Constraint.length(sessionListHeight),
)
if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
val vertRects = Layout.vertical()
.constraints(constraints)
.split(area)
var rectIdx = 0
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
frame.renderWidget(sessionListWidget(state), vertRects[rectIdx++])
if (state.eventStripVisible) {
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx])
}
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
val area = frame.area()
val constraints = mutableListOf(
Constraint.length(STATUS_HEIGHT),
)
if (state.eventStripVisible) {
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
}
constraints.add(Constraint.fill())
constraints.add(Constraint.length(INPUT_HEIGHT))
val vertRects = Layout.vertical()
.constraints(constraints)
.split(area)
var rectIdx = 0
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
if (state.eventStripVisible) {
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
}
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), vertRects[rectIdx])
// Remaining space is unused (padding at the bottom of the left panel)
}
@@ -3,7 +3,6 @@ package com.correx.apps.tui.components
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.selectedPendingApproval
import dev.tamboui.style.Color
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
@@ -17,11 +16,6 @@ import dev.tamboui.widgets.paragraph.Paragraph
private const val PREVIEW_MAX_LINES = 20
private const val DIFF_LINE_MAX_LENGTH = 100
private val dimStyle = Style.create().dim().gray()
private val greenStyle = Style.create().green()
private val redStyle = Style.create().red()
private val yellowStyle = Style.create().yellow()
fun approvalSurfaceWidget(state: TuiState): Paragraph {
val approval = state.selectedPendingApproval()
val pendingDecision = state.pendingDecision
@@ -31,13 +25,13 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph {
val lines = buildList<Line> {
if (approval == null) {
add(Line.from(Span.styled("no pending approval", dimStyle)))
add(Line.from(Span.styled("no pending approval", Theme.dimStyle)))
} else {
// Tool name, tier badge, risk summary
val toolName = approval.toolName ?: "(unknown tool)"
val toolSpan = Span.styled(toolName, Style.create())
val tierBadge = Span.styled("T${approval.tier}", Style.create().yellow())
val riskSpan = Span.styled(approval.riskSummary, dimStyle)
val tierBadge = Span.styled("T${approval.tier}", Theme.warnStyle)
val riskSpan = Span.styled(approval.riskSummary, Theme.dimStyle)
add(Line.from(toolSpan, Span.raw(" "), tierBadge, Span.raw(" "), riskSpan))
// Preview: unified diff for file_write/file_edit, raw text otherwise
@@ -62,10 +56,10 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph {
line
}
val span = when {
truncated.startsWith("@@") -> Span.styled(" $truncated", yellowStyle)
truncated.startsWith("+++") || truncated.startsWith("---") -> Span.styled(" $truncated", dimStyle)
truncated.startsWith("+") -> Span.styled(" $truncated", greenStyle)
truncated.startsWith("-") -> Span.styled(" $truncated", redStyle)
truncated.startsWith("@@") -> Span.styled(" $truncated", Theme.warnStyle)
truncated.startsWith("+++") || truncated.startsWith("---") -> Span.styled(" $truncated", Theme.dimStyle)
truncated.startsWith("+") -> Span.styled(" $truncated", Theme.okStyle)
truncated.startsWith("-") -> Span.styled(" $truncated", Theme.badStyle)
else -> Span.raw(" $truncated")
}
add(Line.from(span))
@@ -75,43 +69,43 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph {
}
if (!diffExpanded && totalLines > PREVIEW_MAX_LINES) {
val remaining = totalLines - PREVIEW_MAX_LINES
add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand, \u2191\u2193 to scroll)", dimStyle)))
add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand, \u2191\u2193 to scroll)", Theme.dimStyle)))
}
if (diffExpanded) {
val showingTo = minOf(scrollOffset + PREVIEW_MAX_LINES, totalLines)
add(Line.from(Span.styled(" lines ${scrollOffset + 1}\u2013$showingTo of $totalLines\u2191\u2193 scroll, ctrl+x collapse", dimStyle)))
add(Line.from(Span.styled(" lines ${scrollOffset + 1}\u2013$showingTo of $totalLines\u2191\u2193 scroll, ctrl+x collapse", Theme.dimStyle)))
}
} else {
add(Line.from(Span.styled(" no preview available", dimStyle)))
add(Line.from(Span.styled(" no preview available", Theme.dimStyle)))
}
} else {
add(Line.from(Span.styled(" no preview available", dimStyle)))
add(Line.from(Span.styled(" no preview available", Theme.dimStyle)))
}
// Steering note input indicator
add(Line.from(Span.styled(" steering note: <type in input bar>", dimStyle)))
add(Line.from(Span.styled(" steering note: <type in input bar>", Theme.dimStyle)))
// Pending decision indicator (color coded)
val decisionSpan = when (pendingDecision) {
null -> Span.styled(" decision: \u2014", dimStyle)
ApprovalDecision.APPROVE -> Span.styled(" decision: APPROVE", Style.create().green())
ApprovalDecision.REJECT -> Span.styled(" decision: REJECT", Style.create().red())
null -> Span.styled(" decision: \u2014", Theme.dimStyle)
ApprovalDecision.APPROVE -> Span.styled(" decision: APPROVE", Theme.okStyle)
ApprovalDecision.REJECT -> Span.styled(" decision: REJECT", Theme.badStyle)
}
add(Line.from(decisionSpan))
// Approve/reject key hints
add(Line.from(Span.styled(
" ctrl+a approve \u00B7 ctrl+r reject \u00B7 ctrl+x toggle diff \u00B7 enter submit \u00B7 esc dismiss",
dimStyle
Theme.dimStyle
)))
}
}
val block = Block.builder()
.title(Title.from(Span.styled("approval", Style.create().yellow())).centered())
.title(Title.from(Span.styled("approval", Theme.warnStyle)).centered())
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.borderColor(Color.YELLOW)
.borderColor(Theme.warn)
.build()
return Paragraph.builder()
@@ -11,12 +11,11 @@ private const val DETAIL_MAX = 60
private const val TYPE_WIDTH = 10
fun eventHistoryStripWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val lines = buildList<Line> {
if (selectedSession == null) {
add(Line.from(Span.styled("no session selected", dimStyle)))
add(Line.from(Span.styled("no session selected", Theme.dimStyle)))
} else {
// Stage ID and status line (compact, dim metadata)
val stageInfo = buildString {
@@ -30,22 +29,22 @@ fun eventHistoryStripWidget(state: TuiState): Paragraph {
append(" ")
append(selectedSession.status)
}
add(Line.from(Span.styled(stageInfo, dimStyle)))
add(Line.from(Span.styled(stageInfo, Theme.dimStyle)))
// Recent events (plain text, compact format) — capped to fit allocated height.
// Line 0 = stage info, lines 1-5 = events (max 5).
val events = selectedSession.recentEvents.takeLast(5)
if (events.isEmpty()) {
add(Line.from(Span.styled("no events", dimStyle)))
add(Line.from(Span.styled("no events", Theme.dimStyle)))
} else {
for (ev in events) {
val typePadded = ev.type.padEnd(TYPE_WIDTH, ' ')
val typeStyle = when (ev.type) {
"StageCompleted", "InferenceCompleted", "ToolCompleted", "SessionCompleted" -> Style.create().green()
"StageFailed", "SessionFailed", "ToolFailed", "ToolRejected" -> Style.create().red()
"SessionPaused", "InferenceTimeout" -> Style.create().yellow()
"StageStarted", "InferenceStarted", "ToolStarted" -> Style.create().blue()
"ArtifactCreated" -> Style.create().magenta()
"StageCompleted", "InferenceCompleted", "ToolCompleted", "SessionCompleted" -> Theme.okStyle
"StageFailed", "SessionFailed", "ToolFailed", "ToolRejected" -> Theme.badStyle
"SessionPaused", "InferenceTimeout" -> Theme.warnStyle
"StageStarted", "InferenceStarted", "ToolStarted" -> Theme.accentStyle
"ArtifactCreated" -> Style.create().fg(Theme.categoryColor("context"))
else -> Style.create()
}
val detail = if (ev.detail.length > DETAIL_MAX) {
@@ -54,7 +53,7 @@ fun eventHistoryStripWidget(state: TuiState): Paragraph {
ev.detail
}
add(Line.from(
Span.styled("[${ev.timestamp}] ", dimStyle),
Span.styled("[${ev.timestamp}] ", Theme.dimStyle),
Span.styled("[$typePadded]", typeStyle),
Span.raw(" $detail"),
))
@@ -0,0 +1,120 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
private const val TIMESTAMP_WIDTH = 8
private const val CATEGORY_WIDTH = 12
private const val TYPE_WIDTH = 18
private const val MAX_VISIBLE_EVENTS = 20
private const val DETAIL_MAX = 40
/**
* Structured event stream panel — right column in IN_SESSION state.
*
* Event row format:
* ```
* HH:MM:SS [Category] Type Detail
* 14:11:02 [Lifecycle] stage.enter implementation (role=coder)
* 14:11:19 [Tool] fs.write SegmentStore.kt (+212)
* ```
*/
fun eventStreamPanelWidget(state: TuiState): Paragraph {
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val lines = buildList<Line> {
// Header: "event stream" + live/paused indicator
val hasPendingApproval = selectedSession?.pendingApproval != null
val liveIndicator = if (hasPendingApproval) {
Span.styled("\u280B paused", Theme.warnStyle) // ⠋ paused
} else {
Span.styled("\u25CC live", Theme.okStyle) // ◌ live
}
add(Line.from(
Span.styled("event stream", Theme.accentStyle),
Span.raw(" "),
Span.styled("\u2502", Theme.dimStyle),
Span.raw(" "),
liveIndicator,
))
// Separator
add(Line.from(Span.styled("\u2500".repeat(40), Theme.dimStyle)))
if (selectedSession == null) {
add(Line.from(Span.styled(" no session selected", Theme.dimStyle)))
} else {
val events = selectedSession.recentEvents.takeLast(MAX_VISIBLE_EVENTS)
if (events.isEmpty()) {
add(Line.from(Span.styled(" no events", Theme.dimStyle)))
} else {
for (ev in events) {
val category = inferCategory(ev.type)
val catColor = Theme.categoryColor(category)
val catStyle = Style.create().fg(catColor)
val timestamp = ev.timestamp.take(TIMESTAMP_WIDTH).padStart(TIMESTAMP_WIDTH)
val categoryPadded = "[$category]".padEnd(CATEGORY_WIDTH)
val typePadded = ev.type.padEnd(TYPE_WIDTH).take(TYPE_WIDTH)
val detail = if (ev.detail.length > DETAIL_MAX) {
ev.detail.take(DETAIL_MAX - 3) + "..."
} else {
ev.detail
}
add(Line.from(
Span.styled(timestamp, Theme.dimStyle),
Span.raw(" "),
Span.styled(categoryPadded, catStyle),
Span.styled(typePadded, Theme.fgStrongStyle),
Span.raw(" "),
Span.styled(detail, Style.create().fg(Theme.fg)),
))
}
}
}
}
val block = Block.builder()
.title("events")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder()
.text(Text.from(lines))
.block(block)
.build()
}
/**
* Infers the event category from the event type name.
*
* | Type contains | Category |
* |------------------|------------|
* | Stage, Lifecycle | Lifecycle |
* | Infer | Inference |
* | Tool | Tool |
* | Session, Artifact| Domain |
* | Approval | Approval |
* | Context | Context |
*/
private fun inferCategory(type: String): String {
val t = type.lowercase()
return when {
t.contains("stage") || t.contains("lifecycle") -> "Lifecycle"
t.contains("infer") -> "Inference"
t.contains("tool") -> "Tool"
t.contains("session") || t.contains("artifact") -> "Domain"
t.contains("approval") -> "Approval"
t.contains("context") -> "Context"
else -> "Lifecycle"
}
}
@@ -0,0 +1,63 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.paragraph.Paragraph
/**
* Bottom bar showing contextual keybind hints and the current mode indicator.
*
* Keybind chip format: `key label` with key in accent and label in dim.
* Mode indicator right-aligned: `Soft · blue`.
*/
fun footerBarWidget(state: TuiState): Paragraph {
val hints = buildList<Span> {
when (state.displayState) {
DisplayState.IDLE -> {
chip("i", "message")
chip("p", "commands")
chip("a", "approval")
chip("e", "events")
chip("F2", "skin")
}
DisplayState.IN_SESSION -> {
chip("i", "message")
chip("p", "commands")
chip("a", "approval")
chip("e", "events")
chip("Tab", "panels")
chip("\u2191\u2193", "history")
chip("l", "back")
chip("F2", "skin")
}
DisplayState.APPROVAL -> {
chip("a", "approve")
chip("A", "auto")
chip("s", "steer")
chip("r", "reject")
chip("esc", "later")
}
}
}
val modeIndicator = Span.styled("Soft \u00B7 blue", Theme.dimStyle)
return Paragraph.builder()
.text(Text.from(Line.from(hints + Span.raw(" ") + modeIndicator)))
.build()
}
/**
* Appends a keybind chip to the builder: ` key label `.
* Key in accent, label in dim, single-space separators.
*/
private fun MutableList<Span>.chip(key: String, label: String) {
add(Span.raw(" "))
add(Span.styled(key, Theme.accentStyle))
add(Span.styled(" $label", Theme.dimStyle))
add(Span.raw(" "))
}
@@ -14,13 +14,13 @@ import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
private fun promptStyle(inputMode: InputMode): Style = when (inputMode) {
InputMode.ROUTER -> Style.create().blue()
InputMode.FILTER -> Style.create().yellow()
InputMode.ROUTER -> Theme.accentStyle
InputMode.FILTER -> Theme.warnStyle
}
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())
com.correx.core.router.ChatMode.CHAT -> Span.styled("chat", Theme.accentStyle)
com.correx.core.router.ChatMode.STEERING -> Span.styled("steer", Theme.accent2Style)
}
private fun cursorLine(buffer: String, cursor: Int, inputMode: InputMode): Line {
@@ -36,8 +36,6 @@ private fun cursorLine(buffer: String, cursor: Int, inputMode: InputMode): Line
}
fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph {
val dimStyle = Style.create().dim().gray()
val sessionName = state.sessions.sessions
.firstOrNull { it.id == state.sessions.selectedId }
?.name?.ifEmpty { null }
@@ -46,18 +44,18 @@ fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph {
val (row1, row2) = when (displayState) {
DisplayState.IDLE -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForIdleRouter(state, dimStyle, sessionName)
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
InputMode.ROUTER -> createRowsForIdleRouter(state, sessionName)
InputMode.FILTER -> createRowsForFilter(state)
}
DisplayState.IN_SESSION -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForInSessionRouter(state, dimStyle, sessionName)
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
InputMode.ROUTER -> createRowsForInSessionRouter(state, sessionName)
InputMode.FILTER -> createRowsForFilter(state)
}
DisplayState.APPROVAL -> when (state.inputMode) {
InputMode.ROUTER -> createRowsForApprovalRouter(state, dimStyle)
InputMode.FILTER -> createRowsForFilter(state, dimStyle)
InputMode.ROUTER -> createRowsForApprovalRouter(state)
InputMode.FILTER -> createRowsForFilter(state)
}
}
@@ -72,104 +70,66 @@ fun inputBarWidget(state: TuiState, displayState: DisplayState): Paragraph {
private fun createRowsForIdleRouter(
state: TuiState,
dimStyle: Style?,
sessionName: String,
): Pair<Line?, Line?> {
val row1 = if (state.inputBuffer.isNotEmpty()) {
cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else {
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Session name…", dimStyle))
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Session name…", Theme.dimStyle))
}
return row1 to Line.from(
Span.styled(sessionName, Style.create().cyan()),
Span.styled(" · ", dimStyle),
Span.styled(sessionName, Theme.accentStyle),
Span.styled(" · ", Theme.dimStyle),
chatModeLabel(state),
Span.styled(" ", dimStyle),
Span.styled("tab", Style.create().blue()),
Span.styled(" filter ", dimStyle),
Span.styled("ctrl+n", Style.create().blue()),
Span.styled(" new ", dimStyle),
Span.styled("ctrl+e", Style.create().blue()),
Span.styled(" events ", dimStyle),
Span.styled("ctrl+q", Style.create().blue()),
Span.styled(" quit", dimStyle),
)
}
private fun createRowsForInSessionRouter(
state: TuiState,
dimStyle: Style?,
sessionName: String,
): Pair<Line?, Line?> {
val row1 = if (state.inputBuffer.isNotEmpty()) {
cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else {
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Ask anything…", dimStyle))
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Ask anything…", Theme.dimStyle))
}
return row1 to Line.from(
Span.styled(sessionName, Style.create().cyan()),
Span.styled(" · ", dimStyle),
Span.styled(sessionName, Theme.accentStyle),
Span.styled(" · ", Theme.dimStyle),
chatModeLabel(state),
Span.styled(" ", dimStyle),
Span.styled("tab", Style.create().blue()),
Span.styled(" filter ", dimStyle),
Span.styled("↑↓", Style.create().blue()),
Span.styled(" history ", dimStyle),
Span.styled("ctrl+e", Style.create().blue()),
Span.styled(" events ", dimStyle),
Span.styled("ctrl+l", Style.create().blue()),
Span.styled(" back ", dimStyle),
Span.styled("ctrl+q", Style.create().blue()),
Span.styled(" quit", dimStyle),
)
}
private fun createRowsForApprovalRouter(
state: TuiState,
dimStyle: Style?,
): Pair<Line?, Line?> {
val row1 = if (state.inputBuffer.isNotEmpty()) {
cursorLine(state.inputBuffer, state.inputCursor, InputMode.ROUTER)
} else {
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Steering note (optional)…", dimStyle))
Line.from(Span.styled("", promptStyle(InputMode.ROUTER)), Span.styled(" Steering note (optional)…", Theme.dimStyle))
}
val decisionSpans = when (state.pendingDecision) {
null -> listOf<Span>()
ApprovalDecision.APPROVE -> listOf(Span.styled(" [decision: ", dimStyle), Span.styled("APPROVE", Style.create().green()), Span.styled("]", dimStyle))
ApprovalDecision.REJECT -> listOf(Span.styled(" [decision: ", dimStyle), Span.styled("REJECT", Style.create().red()), Span.styled("]", dimStyle))
ApprovalDecision.APPROVE -> listOf(Span.styled(" [decision: ", Theme.dimStyle), Span.styled("APPROVE", Theme.okStyle), Span.styled("]", Theme.dimStyle))
ApprovalDecision.REJECT -> listOf(Span.styled(" [decision: ", Theme.dimStyle), Span.styled("REJECT", Theme.badStyle), Span.styled("]", Theme.dimStyle))
}
return row1 to Line.from(
Span.styled("approval", Style.create().yellow()),
Span.styled(" · ", dimStyle),
Span.styled("ctrl+a", Style.create().blue()),
Span.styled(" approve ", dimStyle),
Span.styled("ctrl+r", Style.create().blue()),
Span.styled(" reject ", dimStyle),
Span.styled("", Style.create().blue()),
Span.styled(" enter submit ", dimStyle),
Span.styled("esc", Style.create().blue()),
Span.styled(" dismiss", dimStyle),
Span.styled("approval", Theme.warnStyle),
*decisionSpans.toTypedArray(),
)
}
private fun createRowsForFilter(
state: TuiState,
dimStyle: Style?,
): Pair<Line?, Line?> {
val row1 = if (state.inputBuffer.isNotEmpty()) {
cursorLine(state.inputBuffer, state.inputCursor, InputMode.FILTER)
} else {
Line.from(Span.styled("", promptStyle(InputMode.FILTER)), Span.styled(" /filter…", dimStyle))
Line.from(Span.styled("", promptStyle(InputMode.FILTER)), Span.styled(" /filter…", Theme.dimStyle))
}
return row1 to Line.from(
Span.styled("filtering", Style.create().yellow()),
Span.styled(" ", dimStyle),
Span.styled("esc", Style.create().blue()),
Span.styled(" clear ", dimStyle),
Span.styled("", Style.create().blue()),
Span.styled(" enter apply", dimStyle),
Span.styled("filtering", Theme.warnStyle),
)
}
@@ -1,7 +1,6 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
@@ -11,11 +10,9 @@ import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
fun routerPanelWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val lines = buildList<Line> {
if (!state.routerConnected) {
add(Line.from(Span.styled("not connected — epic 14", dimStyle)))
add(Line.from(Span.styled("not connected — epic 14", Theme.dimStyle)))
} else {
val msgs = state.routerMessages[state.sessions.selectedId].orEmpty()
for (msg in msgs) {
@@ -3,7 +3,6 @@ package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
@@ -18,7 +17,6 @@ private const val ID_LEN = 6
private const val STATUS_WIDTH = 8
fun sessionListWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val sessions = filteredSessions(state.sessions)
val visible = sessions.take(MAX_VISIBLE)
val overflow = sessions.size - MAX_VISIBLE
@@ -26,8 +24,8 @@ fun sessionListWidget(state: TuiState): Paragraph {
val lines = buildList<Line> {
for (s in visible) {
val isSelected = s.id == state.sessions.selectedId
val prefix = if (isSelected) Span.styled("", Style.create().blue()) else Span.raw(" ")
val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", dimStyle)
val prefix = if (isSelected) Span.styled("", Theme.accentStyle) else Span.raw(" ")
val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", Theme.dimStyle)
val statusSpan = statusSpan(s)
val rawName = s.name.ifEmpty { s.workflowId }
val truncated = if (rawName.length > NAME_MAX) rawName.take(NAME_MAX - 1) + "" else rawName
@@ -38,7 +36,7 @@ fun sessionListWidget(state: TuiState): Paragraph {
add(Line.from(prefix, idSpan, Span.raw(" "), statusSpan, Span.raw(" $nameAndStage")))
}
if (overflow > 0) {
add(Line.from(Span.styled("$overflow more —", dimStyle)))
add(Line.from(Span.styled("$overflow more —", Theme.dimStyle)))
}
}
@@ -54,10 +52,10 @@ fun sessionListWidget(state: TuiState): Paragraph {
private fun statusSpan(s: SessionSummary): Span {
val label = s.status.uppercase().padEnd(STATUS_WIDTH)
return when (s.status.lowercase()) {
"active", "running" -> Span.styled(label, Style.create().green())
"paused" -> Span.styled(label, Style.create().yellow())
"failed", "error" -> Span.styled(label, Style.create().red())
"completed", "done" -> Span.styled(label, Style.create().dim().gray())
"active", "running" -> Span.styled(label, Theme.okStyle)
"paused" -> Span.styled(label, Theme.warnStyle)
"failed", "error" -> Span.styled(label, Theme.badStyle)
"completed", "done" -> Span.styled(label, Theme.dimStyle)
else -> Span.raw(label)
}
}
@@ -4,82 +4,116 @@ import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.ProviderType
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.paragraph.Paragraph
private const val METER_BARS = 10
fun statusBarWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val sep = Span.styled(" \u2502 ", dimStyle)
val sep = Span.styled(" \u00B7 ", Theme.dimStyle)
val connSpan = if (state.connection.connected) {
Span.styled("\u25CF", Style.create().green())
// 1. Brand mark: "corre" + accented "x"
val brand = listOf(
Span.styled("corre", Theme.dimStyle),
Span.styled("x", Theme.accentStyle),
)
// 2. Connection indicator
val connIndicator = if (state.connection.connected) {
Span.styled("\u25CF connected", Theme.okStyle)
} else {
Span.styled("\u25CB", Style.create().dim().red())
Span.styled("\u25CB disconnected", Theme.dimBadStyle)
}
val connLabel = if (state.connection.connected) Span.raw(" connected") else Span.raw(" disconnected")
val hostSpan = Span.styled("localhost:8080", dimStyle)
// 3. Session name + branch (only when IN_SESSION or APPROVAL)
val sessionInfo = run {
if (state.displayState == DisplayState.IDLE) return@run emptyList<Span>()
val selected = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
?: return@run emptyList()
val name = selected.name.ifEmpty { selected.workflowId }
val stage = selected.currentStageId ?: selected.currentStage
buildList {
add(Span.styled(name, Theme.fgStrongStyle))
if (stage != null) {
add(Span.styled(" \u00B7 $stage", Theme.dimStyle))
}
}
}
// 4. Model + provider
val modelLabel = (state.currentModel ?: "(no model)") +
if (state.providerType == ProviderType.LOCAL) " (local)" else " (remote)"
val modelSpan = Span.styled(modelLabel, Style.create().cyan())
val modelSpan = Span.styled(modelLabel, Theme.accentStyle)
val sessionsSpan = Span.styled("${state.sessions.sessions.size} sessions", dimStyle)
// 5. Context meter
val contextMeter = run {
val used = state.contextUsed
val budget = state.contextBudget
if (used == null || budget == null || budget <= 0) return@run null
val ratio = (used.toFloat() / budget).coerceIn(0f, 1f)
val filled = (ratio * METER_BARS).toInt().coerceIn(0, METER_BARS)
val empty = METER_BARS - filled
val bar = buildString {
repeat(filled) { append('\u2588') } // █ full block
repeat(empty) { append('\u2591') } // ░ light shade
}
val pct = (ratio * 100).toInt()
val barSpan = Span.styled(bar, Theme.accentStyle)
val pctSpan = Span.styled(" $pct%", Theme.dimStyle)
Span.styled("ctx", Theme.dimStyle) to listOf(barSpan, pctSpan)
}
// Background update badge (+N updated, dim yellow, only when displayState != IDLE)
// 6. State badge
val stateBadge = run {
when (state.displayState) {
DisplayState.APPROVAL -> Span.styled("\u23F8 awaiting", Theme.warnStyle)
DisplayState.IN_SESSION -> Span.styled("\u25B8 active", Theme.okStyle)
DisplayState.IDLE -> null
}
}
// 7. Background update badge
val backgroundBadge = if (state.displayState != DisplayState.IDLE && state.sessions.backgroundUpdateCount > 0) {
Span.styled("+${state.sessions.backgroundUpdateCount} updated", Style.create().dim().yellow())
Span.styled("+${state.sessions.backgroundUpdateCount} updated", Theme.dimWarnStyle)
} else {
null
}
// Compact tool manifest for selected session's current stage
val toolManifestSpan = run {
val selected = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
if (selected != null) {
val stageId = selected.currentStageId
if (stageId != null) {
val tools = selected.toolsByStage[stageId]
if (tools != null && tools.isNotEmpty()) {
val text = tools.joinToString(" \u00B7 ") { tool ->
val suffix = if (tool.tier >= 2) "\u26A0" else ""
"${tool.name} T${tool.tier}$suffix"
}
Span.styled(text, dimStyle)
} else {
Span.styled("tools: \u2014", dimStyle)
}
} else {
null
}
} else {
null
}
// 8. Pending approval indicator
val approvalBadge = run {
val has = state.sessions.sessions.any { it.pendingApproval != null }
if (has) Span.styled("[\u26A0 approval]", Theme.warnStyle) else null
}
val spans = buildList<Span> {
add(connSpan)
add(connLabel)
addAll(brand)
add(sep)
add(hostSpan)
add(connIndicator)
if (sessionInfo.isNotEmpty()) {
add(sep)
addAll(sessionInfo)
}
add(sep)
add(modelSpan)
add(sep)
add(sessionsSpan)
if (contextMeter != null) {
add(sep)
add(contextMeter.first)
add(Span.raw(" "))
addAll(contextMeter.second)
}
if (stateBadge != null) {
add(Span.raw(" "))
add(stateBadge)
}
if (backgroundBadge != null) {
add(sep)
add(backgroundBadge)
}
if (toolManifestSpan != null) {
if (approvalBadge != null) {
add(sep)
add(toolManifestSpan)
}
if (state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval != null) {
add(sep)
add(Span.styled("[\u26A0 approval]", Style.create().yellow()))
add(approvalBadge)
}
}
@@ -0,0 +1,105 @@
package com.correx.apps.tui.components
import dev.tamboui.style.Color
import dev.tamboui.style.Style
/**
* Central theme for the Correx TUI — soft rounded dark palette with blue accent (#4a9eff).
*
* Every widget references Theme.* constants instead of hardcoded `.blue()` / `.cyan()` / `.gray()`.
*
* ## Palette
*
* | Token | Hex | ANSI | Use |
* |-------------|-----------|---------|-------------------------------|
* | `bg` | `#14161a` | idx 233 | Screen background |
* | `bgDeep` | `#0f1115` | idx 232 | Top / footer bar |
* | `panel` | `#181b21` | idx 234 | Panel surface |
* | `panel2` | `#1f232b` | idx 235 | Secondary surface |
* | `fg` | `#ced3da` | idx 252 | Body text |
* | `fgStrong` | `#eef1f5` | idx 255 | Headings, emphasis |
* | `dim` | `#838b96` | idx 243 | Muted text |
* | `faint` | `#434a55` | idx 238 | Borders, placeholders |
* | `border` | `#262b33` | idx 236 | Panel borders |
* | `sel` | `#21262e` | idx 235 | Selection highlight |
* | `accent` | `#4a9eff` | rgb | Blue accent |
* | `accent2` | `#7aa2f7` | rgb | Purple-blue secondary |
* | `ok` | `#7fd88f` | rgb | Success |
* | `warn` | `#e8c06a` | rgb | Warning |
* | `bad` | `#e87f7f` | rgb | Error |
*/
object Theme {
// ── Surfaces ──────────────────────────────────────────────
val bg: Color = Color.indexed(233)
val bgDeep: Color = Color.indexed(232)
val panel: Color = Color.indexed(234)
val panel2: Color = Color.indexed(235)
// ── Text ──────────────────────────────────────────────────
val fg: Color = Color.indexed(252)
val fgStrong: Color = Color.indexed(255)
val dim: Color = Color.indexed(243)
val faint: Color = Color.indexed(238)
// ── Accent ────────────────────────────────────────────────
val accent: Color = Color.rgb(74, 158, 255)
val accent2: Color = Color.rgb(122, 162, 247)
// ── Semantic ──────────────────────────────────────────────
val ok: Color = Color.rgb(127, 216, 143)
val warn: Color = Color.rgb(232, 192, 106)
val bad: Color = Color.rgb(232, 127, 127)
// ── Border / Selection ────────────────────────────────────
val border: Color = Color.indexed(236)
val sel: Color = Color.indexed(235)
// ── Pre-built Style objects ───────────────────────────────
val dimStyle: Style = Style.create().fg(dim)
val accentStyle: Style = Style.create().fg(accent)
val okStyle: Style = Style.create().fg(ok)
val warnStyle: Style = Style.create().fg(warn)
val badStyle: Style = Style.create().fg(bad)
val fgStrongStyle: Style = Style.create().fg(fgStrong)
val accent2Style: Style = Style.create().fg(accent2)
// Convenience: dim + semantic combined styles
val dimOkStyle: Style = Style.create().fg(ok).dim()
val dimWarnStyle: Style = Style.create().fg(warn).dim()
val dimBadStyle: Style = Style.create().fg(bad).dim()
val dimAccentStyle: Style = Style.create().fg(accent).dim()
// ── Typography ────────────────────────────────────────────
/**
* Nominal font size (points). Terminal-emulator controlled — this is a
* documentation / scaling reference for layout constants. Adjust if the
* emulator is configured to a size outside the 14-15pt range.
*/
const val fontSize: Int = 14
// ── Event category → color mapping ────────────────────────
/**
* Maps an event category string to its designated color.
*
* | Category | Color | Hex |
* |------------|---------|-----------|
* | Lifecycle | accent2 | `#7aa2f7` |
* | Context | purple | `#c98fd9` |
* | Inference | accent | `#4a9eff` |
* | Tool | ok | `#7fd88f` |
* | Domain | amber | `#e8b765` |
* | Approval | warn | `#e8c06a` |
*/
fun categoryColor(category: String): Color {
val c = category.lowercase()
return when {
c == "lifecycle" -> accent2
c == "context" -> Color.rgb(201, 143, 217) // purple
c.contains("infer") -> accent // inference / inference
c == "tool" -> ok
c == "domain" -> Color.rgb(232, 183, 101) // amber
c.contains("approval") || c == "approval" -> warn
else -> dim
}
}
}
@@ -0,0 +1,104 @@
package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
import dev.tamboui.widgets.block.Block
import dev.tamboui.widgets.block.BorderType
import dev.tamboui.widgets.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph
private const val RECENT_MAX = 5
private const val NAME_MAX = 20
/**
* Right-column panel in IDLE state showing welcome / quick-start info and recent sessions.
*/
fun welcomePanelWidget(state: TuiState): Paragraph {
val lines = buildList<Line> {
// Brand header with accented x
add(Line.from(
Span.styled("Corre", Theme.fgStrongStyle),
Span.styled("x", Theme.accentStyle),
Span.styled(" Agent Harness", Theme.fgStrongStyle),
))
// Connection status
val connText = if (state.connection.connected) {
"Connected \u00B7 ${state.sessions.sessions.size} sessions"
} else {
"Disconnected"
}
add(Line.from(Span.styled(connText, if (state.connection.connected) Theme.dimStyle else Theme.dimBadStyle)))
add(Line.from(Span.styled("", Theme.dimStyle)))
// Prompt text
add(Line.from(Span.styled("Select a session from the left", Theme.dimStyle)))
add(Line.from(Span.styled("or type a name to start a new one.", Theme.dimStyle)))
add(Line.from(Span.styled("", Theme.dimStyle)))
// Recent sessions
val sorted = state.sessions.sessions
.sortedByDescending { it.lastEventAt }
.take(RECENT_MAX)
if (sorted.isNotEmpty()) {
add(Line.from(Span.styled("recent:", Theme.dimStyle)))
for (s in sorted) {
val glyph = statusGlyph(s.status)
val name = s.name.ifEmpty { s.workflowId }
val truncated = if (name.length > NAME_MAX) name.take(NAME_MAX - 1) + "\u2026" else name
val time = formatTimestamp(s.lastEventAt)
add(Line.from(
Span.raw(" "),
Span.styled(truncated, Style.create().fg(Theme.fg)),
Span.raw(" "),
Span.styled(time, Theme.dimStyle),
Span.raw(" "),
Span.styled(glyph, glyphStyle(s.status)),
))
}
}
}
val block = Block.builder()
.title("welcome")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder()
.text(Text.from(lines))
.block(block)
.build()
}
/** Returns a one-character status glyph for the given session status. */
private fun statusGlyph(status: String): String = when (status.lowercase()) {
"active", "running" -> "\u25B8" // ▸
"completed", "done" -> "\u2714" // ✔
"failed", "error" -> "\u2718" // ✘
"paused" -> "\u23F8" // ⏸
else -> "\u00B7" // ·
}
/** Returns a style for the status glyph. */
private fun glyphStyle(status: String) = when (status.lowercase()) {
"active", "running" -> Theme.accentStyle
"completed", "done" -> Theme.okStyle
"failed", "error" -> Theme.badStyle
"paused" -> Theme.warnStyle
else -> Theme.dimStyle
}
/** Formats a Unix-millis timestamp to a short time string like "14:02". */
private fun formatTimestamp(epochMs: Long): String {
if (epochMs <= 0) return "--:--"
val totalMinutes = (epochMs / 60000) % (24 * 60)
val hh = totalMinutes / 60
val mm = totalMinutes % 60
return "${hh.toInt().toString().padStart(2, '0')}:${mm.toInt().toString().padStart(2, '0')}"
}
@@ -1,8 +1,6 @@
package com.correx.apps.tui.components
import com.correx.apps.server.protocol.WorkflowDto
import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style
import dev.tamboui.text.Line
import dev.tamboui.text.Span
import dev.tamboui.text.Text
@@ -14,28 +12,27 @@ import dev.tamboui.widgets.paragraph.Paragraph
private const val MAX_VISIBLE = 8
fun workflowListWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray()
val workflows = state.sessions.workflows
val visible = workflows.take(MAX_VISIBLE)
val overflow = workflows.size - MAX_VISIBLE
val lines = buildList<Line> {
if (workflows.isEmpty()) {
add(Line.from(Span.styled("(no workflows configured)", dimStyle)))
add(Line.from(Span.styled("(no workflows configured)", Theme.dimStyle)))
} else {
for ((i, wf) in visible.withIndex()) {
val isSelected = i == state.sessions.selectedWorkflowIndex
val prefix = if (isSelected) Span.styled("", Style.create().blue()) else Span.raw(" ")
val name = Span.styled(wf.workflowId, Style.create().cyan())
val prefix = if (isSelected) Span.styled("", Theme.accentStyle) else Span.raw(" ")
val name = Span.styled(wf.workflowId, Theme.accentStyle)
val desc = if (wf.description.isNotBlank() && wf.description != wf.workflowId) {
Span.styled(" · ${wf.description}", dimStyle)
Span.styled(" · ${wf.description}", Theme.dimStyle)
} else {
Span.styled("", dimStyle)
Span.styled("", Theme.dimStyle)
}
add(Line.from(prefix, name, desc))
}
if (overflow > 0) {
add(Line.from(Span.styled("$overflow more —", dimStyle)))
add(Line.from(Span.styled("$overflow more —", Theme.dimStyle)))
}
}
}
@@ -65,6 +65,10 @@ data class TuiState(
* Used to deduplicate events during live streaming phase.
*/
val cursors: Map<String, Long> = emptyMap(),
/** Context usage for the active session (populated from server snapshot data). */
val contextUsed: Int? = null,
/** Context budget for the active session (populated from server snapshot data). */
val contextBudget: Int? = null,
)
data class ToolManifestEntry(