From 46951ea896948e0b7833a7b6854d578ec3e7f7c2 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 17 May 2026 13:54:09 +0400 Subject: [PATCH] =?UTF-8?q?style(tui):=20match=20epic-13=20reference=20lay?= =?UTF-8?q?out=20=E2=80=94=20titled=20panel=20blocks,=20status=20indicator?= =?UTF-8?q?,=20keybinds=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/kotlin/com/correx/apps/tui/TuiApp.kt | 118 ++++++++++-------- .../apps/tui/components/ActiveSession.kt | 25 ++-- .../apps/tui/components/ApprovalPanel.kt | 17 ++- .../correx/apps/tui/components/InputBar.kt | 23 +++- .../correx/apps/tui/components/SessionList.kt | 36 +++--- .../correx/apps/tui/components/StatusBar.kt | 35 ++++-- 6 files changed, 150 insertions(+), 104 deletions(-) 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 ca59f3a4..05872a55 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,9 +1,11 @@ package com.correx.apps.tui -import com.correx.apps.tui.components.inputBarLine -import com.correx.apps.tui.components.renderApprovalPanel +import com.correx.apps.tui.components.activeSessionWidget +import com.correx.apps.tui.components.approvalPanelWidget +import com.correx.apps.tui.components.filteredSessions +import com.correx.apps.tui.components.inputBarWidget import com.correx.apps.tui.components.sessionListWidget -import com.correx.apps.tui.components.statusBarLine +import com.correx.apps.tui.components.statusBarWidget import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.KeyResolver import com.correx.apps.tui.input.mapKey @@ -13,14 +15,17 @@ import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.ws.ConnectionEvent import com.correx.apps.tui.ws.TuiWsClient -import dev.tamboui.layout.Rect +import dev.tamboui.layout.Constraint +import dev.tamboui.layout.Layout import dev.tamboui.style.Style import dev.tamboui.terminal.Frame -import dev.tamboui.text.Text import dev.tamboui.tui.EventHandler import dev.tamboui.tui.Renderer import dev.tamboui.tui.TuiRunner import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent +import dev.tamboui.widgets.block.Block +import dev.tamboui.widgets.block.BorderType +import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.list.ListState import dev.tamboui.widgets.paragraph.Paragraph import kotlinx.coroutines.CoroutineScope @@ -30,9 +35,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking private const val DEFAULT_PORT = 8080 +private const val STATUS_HEIGHT = 3 +private const val ACTIVE_SESSION_HEIGHT = 5 private const val APPROVAL_HEIGHT = 6 -private const val STATUS_ROWS = 1 -private const val HELP_ROWS = 1 +private const val INPUT_HEIGHT = 3 +private const val KEYBINDS_HEIGHT = 3 + +private const val KEYBINDS_TEXT = "n new c cancel a approve r reject s steer / filter q quit" fun main(args: Array) { val host = args.getOrElse(0) { "localhost" } @@ -88,56 +97,55 @@ fun main(args: Array) { } private fun render(frame: Frame, state: TuiState, listState: ListState) { - val total = frame.area() + val area = frame.area() val hasInput = state.input.mode != InputMode.None - val listHeight = if (hasInput) { - total.height() - STATUS_ROWS - HELP_ROWS - 1 - } else { - total.height() - STATUS_ROWS - HELP_ROWS - } - val statusArea = Rect(total.x(), total.y(), total.width(), STATUS_ROWS) - val listArea = Rect(total.x(), total.y() + STATUS_ROWS, total.width(), listHeight) - val inputArea = if (hasInput) { - Rect(total.x(), total.y() + STATUS_ROWS + listHeight, total.width(), 1) - } else { - null - } - val helpArea = Rect(total.x(), total.y() + total.height() - HELP_ROWS, total.width(), HELP_ROWS) + val hasApproval = state.approval.active != null + val constraints = buildList { + add(Constraint.length(STATUS_HEIGHT)) + add(Constraint.fill()) + add(Constraint.length(ACTIVE_SESSION_HEIGHT)) + if (hasApproval) add(Constraint.length(APPROVAL_HEIGHT)) + if (hasInput) add(Constraint.length(INPUT_HEIGHT)) + add(Constraint.length(KEYBINDS_HEIGHT)) + } + + @Suppress("SpreadOperator") + val rects = Layout.vertical() + .constraints(*constraints.toTypedArray()) + .split(area) + + var idx = 0 + + frame.renderWidget(statusBarWidget(state), rects[idx++]) + + val filtered = filteredSessions(state.sessions) + val selectedSession = filtered.firstOrNull { it.id == state.sessions.selectedId } + ?: filtered.firstOrNull() + frame.renderStatefulWidget(sessionListWidget(state.sessions, state.approval, listState), rects[idx++], listState) + + frame.renderWidget(activeSessionWidget(selectedSession), rects[idx++]) + + if (hasApproval) { + frame.renderWidget(approvalPanelWidget(state.approval.active!!), rects[idx++]) + } + + if (hasInput) { + inputBarWidget(state.input)?.let { frame.renderWidget(it, rects[idx]) } + idx++ + } + + val keybindsBlock = Block.builder() + .title("keybinds") + .borders(Borders.ALL) + .borderType(BorderType.ROUNDED) + .build() frame.renderWidget( - Paragraph.builder().text(Text.from(statusBarLine(state))).build(), - statusArea - ) - - frame.renderStatefulWidget(sessionListWidget(state.sessions, listState), listArea, listState) - - if (inputArea != null) { - val line = inputBarLine(state.input) - if (line != null) { - frame.renderWidget( - Paragraph.builder().text(Text.from(line)).build(), - inputArea - ) - } - } - - val approvalActive = state.approval.active - if (approvalActive != null && listHeight > APPROVAL_HEIGHT) { - val approvalY = total.y() + STATUS_ROWS + (listHeight - APPROVAL_HEIGHT).coerceAtLeast(0) - val approvalArea = Rect(total.x(), approvalY, total.width(), APPROVAL_HEIGHT.coerceAtMost(listHeight)) - renderApprovalPanel(frame, approvalActive, approvalArea) - } - - val dimStyle = Style.create().dim().gray() - frame.renderWidget( - Paragraph.builder().text(helpText(state.input.mode)).style(dimStyle).left().build(), - helpArea + Paragraph.builder() + .text(KEYBINDS_TEXT) + .style(Style.create().dim().gray()) + .block(keybindsBlock) + .build(), + rects[idx] ) } - -private fun helpText(mode: InputMode): String = when (mode) { - InputMode.None -> "q quit ↑/↓ select n new / filter a approve r reject s steer c cancel" - InputMode.Filter -> "enter apply esc cancel" - InputMode.WorkflowId -> "enter start session esc cancel" - InputMode.SteeringNote -> "enter send note esc cancel" -} diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt index 70a8d2d0..0a5ca52c 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt @@ -1,21 +1,28 @@ package com.correx.apps.tui.components import com.correx.apps.tui.state.SessionSummary -import dev.tamboui.layout.Rect -import dev.tamboui.terminal.Frame 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 -fun renderActiveSession(frame: Frame, session: SessionSummary?, area: Rect) { +fun activeSessionWidget(session: SessionSummary?): Paragraph { val text = if (session == null) { - Text.from(Line.from(Span.raw("(no active session selected)"))) + Text.from(Line.from(Span.raw("(no session selected)"))) } else { - Text.from( - Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")), - Line.from(Span.raw("last output: ${session.lastOutput ?: "—"}")) - ) + val lines = buildList { + add(Line.from(Span.raw("stage: ${session.currentStage ?: "—"}"))) + session.lastOutput?.let { add(Line.from(Span.raw("last output: $it"))) } + } + Text.from(lines) } - frame.renderWidget(Paragraph.builder().text(text).build(), area) + val block = Block.builder() + .title("active session") + .borders(Borders.ALL) + .borderType(BorderType.ROUNDED) + .build() + return Paragraph.builder().text(text).block(block).build() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt index dd3ac8a4..2b3533b0 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt @@ -1,10 +1,8 @@ package com.correx.apps.tui.components import com.correx.apps.tui.state.ApprovalInfo -import dev.tamboui.layout.Rect import dev.tamboui.style.Color import dev.tamboui.style.Style -import dev.tamboui.terminal.Frame import dev.tamboui.text.Line import dev.tamboui.text.Span import dev.tamboui.text.Text @@ -13,23 +11,22 @@ import dev.tamboui.widgets.block.BorderType import dev.tamboui.widgets.block.Borders import dev.tamboui.widgets.paragraph.Paragraph -fun renderApprovalPanel(frame: Frame, approval: ApprovalInfo, area: Rect) { +fun approvalPanelWidget(approval: ApprovalInfo): Paragraph { val dimStyle = Style.create().dim().gray() val lines = buildList { add(Line.from(Span.styled("⚠ APPROVAL REQUIRED — Tier ${approval.tier}", Style.create().yellow().bold()))) approval.toolName?.let { add(Line.from(Span.raw("tool: $it"))) } - add(Line.from(Span.raw("risk: ${approval.riskSummary}"))) + if (approval.riskSummary.isNotEmpty()) { + add(Line.from(Span.raw("risk: ${approval.riskSummary}"))) + } approval.preview?.let { add(Line.from(Span.raw("preview: $it"))) } - add(Line.from(Span.styled("[A] approve [R] reject", dimStyle))) + add(Line.from(Span.styled("[A] approve [R] reject [S] steer [D] details", dimStyle))) } val block = Block.builder() - .title("approval") + .title("approval prompt") .borders(Borders.ALL) .borderType(BorderType.ROUNDED) .borderColor(Color.YELLOW) .build() - frame.renderWidget( - Paragraph.builder().text(Text.from(lines)).block(block).build(), - area - ) + return Paragraph.builder().text(Text.from(lines)).block(block).build() } 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 d37adae3..9e2f015d 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 @@ -5,17 +5,28 @@ import com.correx.apps.tui.state.InputState 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 -fun inputBarLine(input: InputState): Line? { +fun inputBarWidget(input: InputState): Paragraph? { if (input.mode == InputMode.None) return null val (promptText, promptStyle) = when (input.mode) { - InputMode.WorkflowId -> "new session › " to Style.create().cyan() - InputMode.Filter -> "filter › " to Style.create().yellow() - InputMode.SteeringNote -> "steer › " to Style.create().magenta() + InputMode.WorkflowId -> "new session > " to Style.create().cyan() + InputMode.Filter -> "filter > " to Style.create().yellow() + InputMode.SteeringNote -> "steer > " to Style.create().magenta() InputMode.None -> "" to Style.create() } - return Line.from( + val line = Line.from( Span.styled(promptText, promptStyle), - Span.raw(input.text + "█") + Span.raw("${input.text}_") ) + val block = Block.builder() + .title("input") + .borders(Borders.ALL) + .borderType(BorderType.ROUNDED) + .build() + return Paragraph.builder().text(Text.from(line)).block(block).build() } 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 bcf49f1a..c2eecf64 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 @@ -1,5 +1,6 @@ package com.correx.apps.tui.components +import com.correx.apps.tui.state.ApprovalState import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionsState import dev.tamboui.style.Style @@ -17,9 +18,9 @@ private const val SEC_PER_MIN = 60L private const val SEC_PER_HOUR = 3600L private const val ID_LEN = 6 -fun sessionListWidget(sessions: SessionsState, listState: ListState): ListWidget { +fun sessionListWidget(sessions: SessionsState, approval: ApprovalState, listState: ListState): ListWidget { val filtered = filteredSessions(sessions) - val items = filtered.map { ListItem.from(formatSessionItem(it)) } + val items = filtered.map { ListItem.from(formatSessionItem(it, approval)) } val selIdx = filtered.indexOfFirst { it.id == sessions.selectedId }.let { if (it < 0) 0 else it } listState.select(selIdx) @Suppress("SpreadOperator") @@ -29,7 +30,7 @@ fun sessionListWidget(sessions: SessionsState, listState: ListState): ListWidget .highlightSymbol("▶ ") .block( Block.builder() - .title("sessions") + .title("session list") .borders(Borders.ALL) .borderType(BorderType.ROUNDED) .build() @@ -43,23 +44,30 @@ fun filteredSessions(sessions: SessionsState): List { else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) } } -fun formatSessionItem(s: SessionSummary): Line { +fun formatSessionItem(s: SessionSummary, approval: ApprovalState): Line { val dimStyle = Style.create().dim().gray() - val statusSpan = when (s.status) { - "running" -> Span.styled(s.status, Style.create().green()) - "error" -> Span.styled(s.status, Style.create().red()) - "paused" -> Span.styled(s.status, Style.create().yellow()) - "completed" -> Span.styled(s.status, dimStyle) - "idle" -> Span.styled(s.status, Style.create().cyan()) - else -> Span.raw(s.status) + val statusSpan = when (s.status.lowercase()) { + "running", "active" -> Span.styled(s.status.uppercase(), Style.create().green()) + "error", "failed" -> Span.styled(s.status.uppercase(), Style.create().red()) + "paused" -> Span.styled(s.status.uppercase(), Style.create().yellow()) + "completed", "done" -> Span.styled(s.status.uppercase(), dimStyle) + "idle" -> Span.styled(s.status.uppercase(), Style.create().cyan()) + else -> Span.raw(s.status.uppercase()) + } + val stage = s.currentStage?.let { " stage $it" } ?: "" + val awaitingApproval = approval.active?.sessionId == s.id + val trailing = if (awaitingApproval) { + Span.styled(" awaiting approval", Style.create().yellow()) + } else { + Span.styled(" ${formatAgo(s.lastEventAt)}", dimStyle) } - val stage = s.currentStage?.let { " stage $it" } ?: "" return Line.from( Span.styled("[${s.id.take(ID_LEN)}]", dimStyle), - Span.raw(" \"${s.workflowId}\" "), + Span.raw(" \"${s.workflowId}\""), + Span.raw(" "), statusSpan, Span.raw(stage), - Span.styled(" ${formatAgo(s.lastEventAt)}", dimStyle) + trailing ) } 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 5c7757b3..dfae8229 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,27 +4,32 @@ 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 MS_PER_SEC = 1000L -fun statusBarLine(state: TuiState): Line { +fun statusBarWidget(state: TuiState): Paragraph { val connSpans = when { state.connection.reconnecting -> { val rem = state.connection.nextRetryAtMs?.let { maxOf(0, (it - System.currentTimeMillis()) / MS_PER_SEC) } ?: 0 listOf( - Span.styled("○ ", Style.create().yellow()), - Span.raw("reconnecting (attempt ${state.connection.attempt}, retry in ${rem}s)") + Span.styled("○", Style.create().yellow()), + Span.raw(" reconnecting (attempt ${state.connection.attempt}, retry in ${rem}s)") ) } state.connection.connected -> listOf( - Span.styled("● ", Style.create().green()), - Span.raw("connected") + Span.styled("●", Style.create().green()), + Span.raw(" connected") ) else -> listOf( - Span.styled("○ ", Style.create().dim().gray()), - Span.raw("disconnected") + Span.styled("○", Style.create().dim().gray()), + Span.raw(" disconnected") ) } val prov = if (state.provider.id.isNotEmpty()) { @@ -32,10 +37,20 @@ fun statusBarLine(state: TuiState): Line { } else { state.provider.status } - return Line.from( + val count = state.sessions.sessions.size + val sep = Span.raw(" │ ") + val line = Line.from( connSpans + listOf( - Span.raw(" │ provider: "), - Span.raw(prov) + sep, + Span.raw("provider: $prov"), + sep, + Span.raw("sessions: $count") ) ) + val block = Block.builder() + .title("status bar") + .borders(Borders.ALL) + .borderType(BorderType.ROUNDED) + .build() + return Paragraph.builder().text(Text.from(line)).block(block).build() }