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