From 955f1a698733d272231d50057cced58b3a40a459 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 29 May 2026 20:53:17 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20soft-rounded=20TUI=20layout=20with=20bl?= =?UTF-8?q?ue=20accent=20theme=20=E2=80=94=20all=209=20phases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../main/kotlin/com/correx/apps/tui/TuiApp.kt | 180 +++++++++++------- .../apps/tui/components/ApprovalSurface.kt | 42 ++-- .../apps/tui/components/EventHistoryStrip.kt | 19 +- .../apps/tui/components/EventStreamPanel.kt | 120 ++++++++++++ .../correx/apps/tui/components/FooterBar.kt | 63 ++++++ .../correx/apps/tui/components/InputBar.kt | 84 +++----- .../correx/apps/tui/components/RouterPanel.kt | 5 +- .../correx/apps/tui/components/SessionList.kt | 16 +- .../correx/apps/tui/components/StatusBar.kt | 124 +++++++----- .../com/correx/apps/tui/components/Theme.kt | 105 ++++++++++ .../apps/tui/components/WelcomePanel.kt | 104 ++++++++++ .../apps/tui/components/WorkflowListPanel.kt | 15 +- .../com/correx/apps/tui/state/TuiState.kt | 4 + 13 files changed, 646 insertions(+), 235 deletions(-) create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventStreamPanel.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/FooterBar.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/Theme.kt create mode 100644 apps/tui/src/main/kotlin/com/correx/apps/tui/components/WelcomePanel.kt diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt index 230d0e8f..a89fff11 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt @@ -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) { 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) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt index 3188e3c8..a73e86c6 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt @@ -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 { 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: ", dimStyle))) + add(Line.from(Span.styled(" steering note: ", 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() diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt index 0eae49b6..6fd102aa 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt @@ -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 { 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"), )) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventStreamPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventStreamPanel.kt new file mode 100644 index 00000000..392fb6f9 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventStreamPanel.kt @@ -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 { + // 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" + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/FooterBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/FooterBar.kt new file mode 100644 index 00000000..3c8ef146 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/FooterBar.kt @@ -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 { + 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.chip(key: String, label: String) { + add(Span.raw(" ")) + add(Span.styled(key, Theme.accentStyle)) + add(Span.styled(" $label", Theme.dimStyle)) + add(Span.raw(" ")) +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt index 92136c97..4412ebac 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt @@ -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 { 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 { 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 { 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() - 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 { 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), ) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt index 9065a325..d179cc1c 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/RouterPanel.kt @@ -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 { 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) { diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt index caa60103..325c8509 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt @@ -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 { 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) } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt index 9c4e48a4..c1eee449 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt @@ -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() + 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 { - 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) } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Theme.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Theme.kt new file mode 100644 index 00000000..c7214fd9 --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/Theme.kt @@ -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 + } + } +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WelcomePanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WelcomePanel.kt new file mode 100644 index 00000000..55bc17be --- /dev/null +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WelcomePanel.kt @@ -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 { + // 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')}" +} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt index cafd3308..87ee36bb 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/WorkflowListPanel.kt @@ -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 { 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))) } } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt index 16aa5fcf..808a58b3 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt @@ -65,6 +65,10 @@ data class TuiState( * Used to deduplicate events during live streaming phase. */ val cursors: Map = 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(