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(