style(tui): match epic-13 reference layout — titled panel blocks, status indicator, keybinds bar

This commit is contained in:
2026-05-17 13:54:09 +04:00
parent b267982005
commit 46951ea896
6 changed files with 150 additions and 104 deletions
@@ -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<String>) {
val host = args.getOrElse(0) { "localhost" }
@@ -88,56 +97,55 @@ fun main(args: Array<String>) {
}
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 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))
}
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
@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++])
}
val helpArea = Rect(total.x(), total.y() + total.height() - HELP_ROWS, total.width(), HELP_ROWS)
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
Paragraph.builder()
.text(KEYBINDS_TEXT)
.style(Style.create().dim().gray())
.block(keybindsBlock)
.build(),
rects[idx]
)
}
}
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
)
}
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"
}
@@ -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<Line> {
add(Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")))
session.lastOutput?.let { add(Line.from(Span.raw("last output: $it"))) }
}
frame.renderWidget(Paragraph.builder().text(text).build(), area)
Text.from(lines)
}
val block = Block.builder()
.title("active session")
.borders(Borders.ALL)
.borderType(BorderType.ROUNDED)
.build()
return Paragraph.builder().text(text).block(block).build()
}
@@ -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<Line> {
add(Line.from(Span.styled("⚠ APPROVAL REQUIRED — Tier ${approval.tier}", Style.create().yellow().bold())))
approval.toolName?.let { add(Line.from(Span.raw("tool: $it"))) }
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()
}
@@ -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()
}
@@ -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<SessionSummary> {
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)
}
return Line.from(
Span.styled("[${s.id.take(ID_LEN)}]", dimStyle),
Span.raw(" \"${s.workflowId}\""),
Span.raw(" "),
statusSpan,
Span.raw(stage),
Span.styled(" ${formatAgo(s.lastEventAt)}", dimStyle)
trailing
)
}
@@ -4,10 +4,15 @@ 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 {
@@ -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()
}