style(tui): match epic-13 reference layout — titled panel blocks, status indicator, keybinds bar
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user