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 package com.correx.apps.tui
import com.correx.apps.tui.components.inputBarLine import com.correx.apps.tui.components.activeSessionWidget
import com.correx.apps.tui.components.renderApprovalPanel 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.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.Action
import com.correx.apps.tui.input.KeyResolver import com.correx.apps.tui.input.KeyResolver
import com.correx.apps.tui.input.mapKey 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.state.TuiState
import com.correx.apps.tui.ws.ConnectionEvent import com.correx.apps.tui.ws.ConnectionEvent
import com.correx.apps.tui.ws.TuiWsClient 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.style.Style
import dev.tamboui.terminal.Frame import dev.tamboui.terminal.Frame
import dev.tamboui.text.Text
import dev.tamboui.tui.EventHandler import dev.tamboui.tui.EventHandler
import dev.tamboui.tui.Renderer import dev.tamboui.tui.Renderer
import dev.tamboui.tui.TuiRunner import dev.tamboui.tui.TuiRunner
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent 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.list.ListState
import dev.tamboui.widgets.paragraph.Paragraph import dev.tamboui.widgets.paragraph.Paragraph
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -30,9 +35,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
private const val DEFAULT_PORT = 8080 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 APPROVAL_HEIGHT = 6
private const val STATUS_ROWS = 1 private const val INPUT_HEIGHT = 3
private const val HELP_ROWS = 1 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>) { fun main(args: Array<String>) {
val host = args.getOrElse(0) { "localhost" } val host = args.getOrElse(0) { "localhost" }
@@ -88,56 +97,55 @@ fun main(args: Array<String>) {
} }
private fun render(frame: Frame, state: TuiState, listState: ListState) { 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 hasInput = state.input.mode != InputMode.None
val listHeight = if (hasInput) { val hasApproval = state.approval.active != null
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 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( frame.renderWidget(
Paragraph.builder().text(Text.from(statusBarLine(state))).build(), Paragraph.builder()
statusArea .text(KEYBINDS_TEXT)
) .style(Style.create().dim().gray())
.block(keybindsBlock)
frame.renderStatefulWidget(sessionListWidget(state.sessions, listState), listArea, listState) .build(),
rects[idx]
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
) )
} }
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 package com.correx.apps.tui.components
import com.correx.apps.tui.state.SessionSummary 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.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
import dev.tamboui.text.Text 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 import dev.tamboui.widgets.paragraph.Paragraph
fun renderActiveSession(frame: Frame, session: SessionSummary?, area: Rect) { fun activeSessionWidget(session: SessionSummary?): Paragraph {
val text = if (session == null) { 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 { } else {
Text.from( val lines = buildList<Line> {
Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")), add(Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")))
Line.from(Span.raw("last output: ${session.lastOutput ?: "—"}")) 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 package com.correx.apps.tui.components
import com.correx.apps.tui.state.ApprovalInfo import com.correx.apps.tui.state.ApprovalInfo
import dev.tamboui.layout.Rect
import dev.tamboui.style.Color import dev.tamboui.style.Color
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.terminal.Frame
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
import dev.tamboui.text.Text 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.block.Borders
import dev.tamboui.widgets.paragraph.Paragraph 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 dimStyle = Style.create().dim().gray()
val lines = buildList<Line> { val lines = buildList<Line> {
add(Line.from(Span.styled("⚠ APPROVAL REQUIRED — Tier ${approval.tier}", Style.create().yellow().bold()))) add(Line.from(Span.styled("⚠ APPROVAL REQUIRED — Tier ${approval.tier}", Style.create().yellow().bold())))
approval.toolName?.let { add(Line.from(Span.raw("tool: $it"))) } approval.toolName?.let { add(Line.from(Span.raw("tool: $it"))) }
if (approval.riskSummary.isNotEmpty()) {
add(Line.from(Span.raw("risk: ${approval.riskSummary}"))) add(Line.from(Span.raw("risk: ${approval.riskSummary}")))
}
approval.preview?.let { add(Line.from(Span.raw("preview: $it"))) } 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() val block = Block.builder()
.title("approval") .title("approval prompt")
.borders(Borders.ALL) .borders(Borders.ALL)
.borderType(BorderType.ROUNDED) .borderType(BorderType.ROUNDED)
.borderColor(Color.YELLOW) .borderColor(Color.YELLOW)
.build() .build()
frame.renderWidget( return Paragraph.builder().text(Text.from(lines)).block(block).build()
Paragraph.builder().text(Text.from(lines)).block(block).build(),
area
)
} }
@@ -5,17 +5,28 @@ import com.correx.apps.tui.state.InputState
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span 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 if (input.mode == InputMode.None) return null
val (promptText, promptStyle) = when (input.mode) { val (promptText, promptStyle) = when (input.mode) {
InputMode.WorkflowId -> "new session " to Style.create().cyan() InputMode.WorkflowId -> "new session > " to Style.create().cyan()
InputMode.Filter -> "filter " to Style.create().yellow() InputMode.Filter -> "filter > " to Style.create().yellow()
InputMode.SteeringNote -> "steer " to Style.create().magenta() InputMode.SteeringNote -> "steer > " to Style.create().magenta()
InputMode.None -> "" to Style.create() InputMode.None -> "" to Style.create()
} }
return Line.from( val line = Line.from(
Span.styled(promptText, promptStyle), 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 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.SessionSummary
import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.SessionsState
import dev.tamboui.style.Style 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 SEC_PER_HOUR = 3600L
private const val ID_LEN = 6 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 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 } val selIdx = filtered.indexOfFirst { it.id == sessions.selectedId }.let { if (it < 0) 0 else it }
listState.select(selIdx) listState.select(selIdx)
@Suppress("SpreadOperator") @Suppress("SpreadOperator")
@@ -29,7 +30,7 @@ fun sessionListWidget(sessions: SessionsState, listState: ListState): ListWidget
.highlightSymbol("") .highlightSymbol("")
.block( .block(
Block.builder() Block.builder()
.title("sessions") .title("session list")
.borders(Borders.ALL) .borders(Borders.ALL)
.borderType(BorderType.ROUNDED) .borderType(BorderType.ROUNDED)
.build() .build()
@@ -43,23 +44,30 @@ fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) } 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 dimStyle = Style.create().dim().gray()
val statusSpan = when (s.status) { val statusSpan = when (s.status.lowercase()) {
"running" -> Span.styled(s.status, Style.create().green()) "running", "active" -> Span.styled(s.status.uppercase(), Style.create().green())
"error" -> Span.styled(s.status, Style.create().red()) "error", "failed" -> Span.styled(s.status.uppercase(), Style.create().red())
"paused" -> Span.styled(s.status, Style.create().yellow()) "paused" -> Span.styled(s.status.uppercase(), Style.create().yellow())
"completed" -> Span.styled(s.status, dimStyle) "completed", "done" -> Span.styled(s.status.uppercase(), dimStyle)
"idle" -> Span.styled(s.status, Style.create().cyan()) "idle" -> Span.styled(s.status.uppercase(), Style.create().cyan())
else -> Span.raw(s.status) else -> Span.raw(s.status.uppercase())
} }
val stage = s.currentStage?.let { " stage $it" } ?: "" 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( return Line.from(
Span.styled("[${s.id.take(ID_LEN)}]", dimStyle), Span.styled("[${s.id.take(ID_LEN)}]", dimStyle),
Span.raw(" \"${s.workflowId}\" "), Span.raw(" \"${s.workflowId}\""),
Span.raw(" "),
statusSpan, statusSpan,
Span.raw(stage), Span.raw(stage),
Span.styled(" ${formatAgo(s.lastEventAt)}", dimStyle) trailing
) )
} }
@@ -4,27 +4,32 @@ import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span 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 private const val MS_PER_SEC = 1000L
fun statusBarLine(state: TuiState): Line { fun statusBarWidget(state: TuiState): Paragraph {
val connSpans = when { val connSpans = when {
state.connection.reconnecting -> { state.connection.reconnecting -> {
val rem = state.connection.nextRetryAtMs?.let { val rem = state.connection.nextRetryAtMs?.let {
maxOf(0, (it - System.currentTimeMillis()) / MS_PER_SEC) maxOf(0, (it - System.currentTimeMillis()) / MS_PER_SEC)
} ?: 0 } ?: 0
listOf( listOf(
Span.styled(" ", Style.create().yellow()), Span.styled("", Style.create().yellow()),
Span.raw("reconnecting (attempt ${state.connection.attempt}, retry in ${rem}s)") Span.raw(" reconnecting (attempt ${state.connection.attempt}, retry in ${rem}s)")
) )
} }
state.connection.connected -> listOf( state.connection.connected -> listOf(
Span.styled(" ", Style.create().green()), Span.styled("", Style.create().green()),
Span.raw("connected") Span.raw(" connected")
) )
else -> listOf( else -> listOf(
Span.styled(" ", Style.create().dim().gray()), Span.styled("", Style.create().dim().gray()),
Span.raw("disconnected") Span.raw(" disconnected")
) )
} }
val prov = if (state.provider.id.isNotEmpty()) { val prov = if (state.provider.id.isNotEmpty()) {
@@ -32,10 +37,20 @@ fun statusBarLine(state: TuiState): Line {
} else { } else {
state.provider.status state.provider.status
} }
return Line.from( val count = state.sessions.sessions.size
val sep = Span.raw("")
val line = Line.from(
connSpans + listOf( connSpans + listOf(
Span.raw(" │ provider: "), sep,
Span.raw(prov) 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()
} }