refactor(tui): migrate renderer from Mosaic to tamboui
Replaces the Mosaic 0.18 string-frame renderer with tamboui 0.2.1-SNAPSHOT to unlock a cell buffer, widget catalog, and real layout engine. Domain core (state/reducer/ws/input) is unchanged; only the renderer and the key mapper are rewritten. Fixes a latent Mosaic-mapper bug where q/n/c/a/r/s// were swallowed inside input modes by making the new mapper mode-aware.
This commit is contained in:
+30
-3
@@ -1,12 +1,22 @@
|
||||
plugins {
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
id 'org.jetbrains.kotlin.plugin.compose'
|
||||
id 'application'
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass = 'com.correx.apps.tui.MainKt'
|
||||
mainClass = 'com.correx.apps.tui.TuiAppKt'
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url 'https://central.sonatype.com/repository/maven-snapshots/'
|
||||
mavenContent { snapshotsOnly() }
|
||||
}
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -19,7 +29,11 @@ dependencies {
|
||||
implementation "io.ktor:ktor-client-websockets:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-logging:$ktor_version"
|
||||
|
||||
implementation "com.jakewharton.mosaic:mosaic-runtime:0.18.0"
|
||||
implementation platform('dev.tamboui:tamboui-bom:0.2.1-SNAPSHOT')
|
||||
implementation 'dev.tamboui:tamboui-tui'
|
||||
implementation 'dev.tamboui:tamboui-widgets'
|
||||
implementation 'dev.tamboui:tamboui-core'
|
||||
runtimeOnly 'dev.tamboui:tamboui-jline3-backend'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version"
|
||||
|
||||
@@ -31,6 +45,19 @@ dependencies {
|
||||
|
||||
tasks.named('test') { useJUnitPlatform() }
|
||||
|
||||
tasks.register('tuiStartScripts', CreateStartScripts) {
|
||||
mainClass = 'com.correx.apps.tui.TuiAppKt'
|
||||
applicationName = 'tui'
|
||||
outputDir = file("$buildDir/scripts-tui")
|
||||
classpath = tasks.named('startScripts').get().classpath
|
||||
}
|
||||
|
||||
distributions.named('main').configure {
|
||||
contents {
|
||||
from(tasks.named('tuiStartScripts')) { into 'bin' }
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(Tar).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||
tasks.withType(Zip).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||
tasks.named("installDist") { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.correx.apps.tui
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private const val DEFAULT_PORT = 8080
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val host = args.getOrElse(0) { "localhost" }
|
||||
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
|
||||
runBlocking { runTuiApp(host, port) }
|
||||
}
|
||||
@@ -1,111 +1,143 @@
|
||||
package com.correx.apps.tui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import com.correx.apps.tui.components.ActiveSession
|
||||
import com.correx.apps.tui.components.ApprovalPanel
|
||||
import com.correx.apps.tui.components.InputBar
|
||||
import com.correx.apps.tui.components.LocalTerminalSize
|
||||
import com.correx.apps.tui.components.Panel
|
||||
import com.correx.apps.tui.components.SessionList
|
||||
import com.correx.apps.tui.components.StatusBar
|
||||
import com.correx.apps.tui.components.readTerminalSize
|
||||
import com.correx.apps.tui.components.inputBarLine
|
||||
import com.correx.apps.tui.components.renderApprovalPanel
|
||||
import com.correx.apps.tui.components.sessionListWidget
|
||||
import com.correx.apps.tui.components.statusBarLine
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.input.KeyResolver
|
||||
import com.correx.apps.tui.input.MosaicKeyMapper
|
||||
import com.correx.apps.tui.input.mapKey
|
||||
import com.correx.apps.tui.reducer.EffectDispatcher
|
||||
import com.correx.apps.tui.reducer.Effect
|
||||
import com.correx.apps.tui.reducer.RootReducer
|
||||
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 com.jakewharton.mosaic.layout.onKeyEvent
|
||||
import com.jakewharton.mosaic.modifier.Modifier
|
||||
import com.jakewharton.mosaic.runMosaic
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import dev.tamboui.layout.Rect
|
||||
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.list.ListState
|
||||
import dev.tamboui.widgets.paragraph.Paragraph
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private const val TERMINAL_SIZE_POLL_MS = 500L
|
||||
private const val DEFAULT_PORT = 8080
|
||||
private const val APPROVAL_HEIGHT = 6
|
||||
private const val STATUS_ROWS = 1
|
||||
private const val HELP_ROWS = 1
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
private fun Keybinds() {
|
||||
Text("n new r resume c cancel a approve q quit ↑↓ navigate")
|
||||
}
|
||||
fun main(args: Array<String>) {
|
||||
val host = args.getOrElse(0) { "localhost" }
|
||||
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
|
||||
runBlocking {
|
||||
var state = TuiState()
|
||||
val ws = TuiWsClient(host = host, port = port)
|
||||
val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
val listState = ListState()
|
||||
|
||||
suspend fun runTuiApp(host: String, port: Int) {
|
||||
var state by mutableStateOf(TuiState())
|
||||
var quit by mutableStateOf(false)
|
||||
var terminalSize by mutableStateOf(readTerminalSize())
|
||||
|
||||
val effects = Channel<Effect>(Channel.BUFFERED)
|
||||
val ws = TuiWsClient(host = host, port = port)
|
||||
val dispatcher = EffectDispatcher(ws) { quit = true }
|
||||
|
||||
fun dispatch(action: Action) {
|
||||
val (next, emitted) = RootReducer.reduce(state, action)
|
||||
Snapshot.withMutableSnapshot { state = next }
|
||||
emitted.forEach { effects.trySend(it) }
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
launch { ws.connect() }
|
||||
launch { ws.messages.collect { dispatch(Action.ServerEventReceived(it)) } }
|
||||
launch {
|
||||
ws.connection.collect { event ->
|
||||
dispatch(
|
||||
when (event) {
|
||||
is ConnectionEvent.Connected -> Action.Connected
|
||||
is ConnectionEvent.Disconnected -> Action.Disconnected
|
||||
is ConnectionEvent.RetryScheduled -> Action.RetryScheduled(event.attempt, event.nextRetryAtMs)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
launch { for (e in effects) dispatcher.dispatch(e) }
|
||||
launch {
|
||||
while (isActive) {
|
||||
delay(TERMINAL_SIZE_POLL_MS)
|
||||
val current = readTerminalSize()
|
||||
if (current != terminalSize) Snapshot.withMutableSnapshot { terminalSize = current }
|
||||
}
|
||||
}
|
||||
|
||||
runMosaic {
|
||||
CompositionLocalProvider(LocalTerminalSize provides terminalSize) {
|
||||
Column(
|
||||
modifier = Modifier.onKeyEvent { mosaicKey ->
|
||||
val key = MosaicKeyMapper.toDomain(mosaicKey) ?: return@onKeyEvent false
|
||||
val action = KeyResolver.resolve(key, state.input.mode, state.input.text)
|
||||
?: return@onKeyEvent true
|
||||
dispatch(action)
|
||||
true
|
||||
},
|
||||
) {
|
||||
Panel(title = "status") { StatusBar(state) }
|
||||
Panel(title = "session list") {
|
||||
SessionList(state.sessions.sessions, state.sessions.selectedId, state.sessions.filter)
|
||||
TuiRunner.create().use { runner ->
|
||||
fun dispatch(action: Action) {
|
||||
val (next, effects) = RootReducer.reduce(state, action)
|
||||
state = next
|
||||
effects.forEach { effect ->
|
||||
effectScope.launch {
|
||||
EffectDispatcher(ws) { runner.quit() }.dispatch(effect)
|
||||
}
|
||||
val activeSession = state.sessions.sessions.find { it.id == state.sessions.selectedId }
|
||||
Panel(title = "active session") { ActiveSession(activeSession) }
|
||||
if (state.approval.active != null) {
|
||||
Panel(title = "approval") { ApprovalPanel(state.approval.active!!) }
|
||||
}
|
||||
Panel(title = "input") { InputBar(state.input) }
|
||||
Panel(title = "keybinds") { Keybinds() }
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
ws.messages.collect { msg ->
|
||||
runner.runOnRenderThread { dispatch(Action.ServerEventReceived(msg)) }
|
||||
}
|
||||
}
|
||||
launch {
|
||||
ws.connection.collect { ev ->
|
||||
val action = when (ev) {
|
||||
is ConnectionEvent.Connected -> Action.Connected
|
||||
is ConnectionEvent.Disconnected -> Action.Disconnected
|
||||
is ConnectionEvent.RetryScheduled -> Action.RetryScheduled(ev.attempt, ev.nextRetryAtMs)
|
||||
}
|
||||
runner.runOnRenderThread { dispatch(action) }
|
||||
}
|
||||
}
|
||||
launch { ws.connect() }
|
||||
|
||||
val handler = EventHandler { event, _ ->
|
||||
if (event is TambouiKeyEvent) {
|
||||
mapKey(event, state.input.mode)
|
||||
?.let { KeyResolver.resolve(it, state.input.mode, state.input.text) }
|
||||
?.let(::dispatch)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
val renderer = Renderer { frame -> render(frame, state, listState) }
|
||||
|
||||
runner.run(handler, renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(frame: Frame, state: TuiState, listState: ListState) {
|
||||
val total = 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)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ws.close()
|
||||
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,19 +1,21 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.correx.apps.tui.state.SessionSummary
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
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.paragraph.Paragraph
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun ActiveSession(session: SessionSummary?) {
|
||||
Column {
|
||||
if (session == null) {
|
||||
Text("(no active session selected)")
|
||||
} else {
|
||||
Text("stage: ${session.currentStage ?: "—"}")
|
||||
Text("last output: ${session.lastOutput ?: "—"}")
|
||||
}
|
||||
fun renderActiveSession(frame: Frame, session: SessionSummary?, area: Rect) {
|
||||
val text = if (session == null) {
|
||||
Text.from(Line.from(Span.raw("(no active session selected)")))
|
||||
} else {
|
||||
Text.from(
|
||||
Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")),
|
||||
Line.from(Span.raw("last output: ${session.lastOutput ?: "—"}"))
|
||||
)
|
||||
}
|
||||
frame.renderWidget(Paragraph.builder().text(text).build(), area)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.correx.apps.tui.state.ApprovalInfo
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
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
|
||||
import dev.tamboui.widgets.block.Block
|
||||
import dev.tamboui.widgets.block.BorderType
|
||||
import dev.tamboui.widgets.block.Borders
|
||||
import dev.tamboui.widgets.paragraph.Paragraph
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun ApprovalPanel(approval: ApprovalInfo) {
|
||||
Column {
|
||||
Text("⚠ APPROVAL REQUIRED — Tier ${approval.tier}")
|
||||
approval.toolName?.let { Text("tool: $it") }
|
||||
Text("risk: ${approval.riskSummary}")
|
||||
approval.preview?.let { Text("preview: $it") }
|
||||
Text("[A] approve [R] reject [S] steer")
|
||||
fun renderApprovalPanel(frame: Frame, approval: ApprovalInfo, area: Rect) {
|
||||
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"))) }
|
||||
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)))
|
||||
}
|
||||
val block = Block.builder()
|
||||
.title("approval")
|
||||
.borders(Borders.ALL)
|
||||
.borderType(BorderType.ROUNDED)
|
||||
.borderColor(Color.YELLOW)
|
||||
.build()
|
||||
frame.renderWidget(
|
||||
Paragraph.builder().text(Text.from(lines)).block(block).build(),
|
||||
area
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.apps.tui.state.InputState
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import dev.tamboui.style.Style
|
||||
import dev.tamboui.text.Line
|
||||
import dev.tamboui.text.Span
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun InputBar(input: InputState) {
|
||||
val label = when (input.mode) {
|
||||
InputMode.None -> ">"
|
||||
InputMode.WorkflowId -> "workflow id:"
|
||||
InputMode.SteeringNote -> "steering note:"
|
||||
InputMode.Filter -> "filter:"
|
||||
fun inputBarLine(input: InputState): Line? {
|
||||
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.None -> "" to Style.create()
|
||||
}
|
||||
Text("$label ${input.text}▌")
|
||||
return Line.from(
|
||||
Span.styled(promptText, promptStyle),
|
||||
Span.raw(input.text + "█")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun Panel(title: String?, content: @Composable () -> Unit) {
|
||||
val width = LocalTerminalSize.current.width
|
||||
Column {
|
||||
Text(headerLine(title, width))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private fun headerLine(title: String?, width: Int): String {
|
||||
if (title == null) return "├" + "─".repeat((width - 2).coerceAtLeast(0)) + "┤"
|
||||
val label = "─ $title "
|
||||
val fill = (width - 2 - label.length).coerceAtLeast(0)
|
||||
return "├$label" + "─".repeat(fill) + "┤"
|
||||
}
|
||||
@@ -1,44 +1,73 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.correx.apps.tui.state.SessionSummary
|
||||
import com.jakewharton.mosaic.ui.Column
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import com.correx.apps.tui.state.SessionsState
|
||||
import dev.tamboui.style.Style
|
||||
import dev.tamboui.text.Line
|
||||
import dev.tamboui.text.Span
|
||||
import dev.tamboui.widgets.block.Block
|
||||
import dev.tamboui.widgets.block.BorderType
|
||||
import dev.tamboui.widgets.block.Borders
|
||||
import dev.tamboui.widgets.list.ListItem
|
||||
import dev.tamboui.widgets.list.ListState
|
||||
import dev.tamboui.widgets.list.ListWidget
|
||||
|
||||
private const val SESSION_ID_DISPLAY_LENGTH = 6
|
||||
private const val MS_PER_SECOND = 1000L
|
||||
private const val SECS_PER_MINUTE = 60L
|
||||
private const val SECS_PER_HOUR = 3600L
|
||||
private const val MS_PER_SEC = 1000L
|
||||
private const val SEC_PER_MIN = 60L
|
||||
private const val SEC_PER_HOUR = 3600L
|
||||
private const val ID_LEN = 6
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun SessionList(sessions: List<SessionSummary>, selectedId: String?, filter: String = "") {
|
||||
Column {
|
||||
val filtered = if (filter.isEmpty()) {
|
||||
sessions
|
||||
} else {
|
||||
sessions.filter { it.workflowId.contains(filter, ignoreCase = true) }
|
||||
}
|
||||
if (filtered.isEmpty()) {
|
||||
Text("(no sessions)")
|
||||
} else {
|
||||
filtered.forEach { session ->
|
||||
val pointer = if (session.id == selectedId) "▶" else " "
|
||||
val stage = session.currentStage?.let { " stage $it" } ?: ""
|
||||
val ago = formatAgo(session.lastEventAt)
|
||||
val shortId = session.id.take(SESSION_ID_DISPLAY_LENGTH)
|
||||
Text("$pointer [$shortId] \"${session.workflowId}\" ${session.status}$stage $ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
fun sessionListWidget(sessions: SessionsState, listState: ListState): ListWidget {
|
||||
val filtered = filteredSessions(sessions)
|
||||
val items = filtered.map { ListItem.from(formatSessionItem(it)) }
|
||||
val selIdx = filtered.indexOfFirst { it.id == sessions.selectedId }.let { if (it < 0) 0 else it }
|
||||
listState.select(selIdx)
|
||||
@Suppress("SpreadOperator")
|
||||
return ListWidget.builder()
|
||||
.items(*items.toTypedArray())
|
||||
.highlightStyle(Style.create().cyan().reversed())
|
||||
.highlightSymbol("▶ ")
|
||||
.block(
|
||||
Block.builder()
|
||||
.title("sessions")
|
||||
.borders(Borders.ALL)
|
||||
.borderType(BorderType.ROUNDED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun formatAgo(epochMs: Long): String {
|
||||
val diffMs = System.currentTimeMillis() - epochMs
|
||||
val secs = diffMs / MS_PER_SECOND
|
||||
fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
|
||||
val f = sessions.filter
|
||||
return if (f.isEmpty()) sessions.sessions
|
||||
else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) }
|
||||
}
|
||||
|
||||
fun formatSessionItem(s: SessionSummary): 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 stage = s.currentStage?.let { " stage $it" } ?: ""
|
||||
return Line.from(
|
||||
Span.styled("[${s.id.take(ID_LEN)}]", dimStyle),
|
||||
Span.raw(" \"${s.workflowId}\" "),
|
||||
statusSpan,
|
||||
Span.raw(stage),
|
||||
Span.styled(" ${formatAgo(s.lastEventAt)}", dimStyle)
|
||||
)
|
||||
}
|
||||
|
||||
fun formatAgo(epochMs: Long): String {
|
||||
val secs = (System.currentTimeMillis() - epochMs) / MS_PER_SEC
|
||||
return when {
|
||||
secs < SECS_PER_MINUTE -> "${secs}s ago"
|
||||
secs < SECS_PER_HOUR -> "${secs / SECS_PER_MINUTE}m ago"
|
||||
else -> "${secs / SECS_PER_HOUR}h ago"
|
||||
secs < SEC_PER_MIN -> "${secs}s ago"
|
||||
secs < SEC_PER_HOUR -> "${secs / SEC_PER_MIN}m ago"
|
||||
else -> "${secs / SEC_PER_HOUR}h ago"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
import dev.tamboui.style.Style
|
||||
import dev.tamboui.text.Line
|
||||
import dev.tamboui.text.Span
|
||||
|
||||
private const val MS_PER_SECOND = 1000L
|
||||
private const val MS_PER_SEC = 1000L
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun StatusBar(state: TuiState) {
|
||||
val connectionLabel = when {
|
||||
fun statusBarLine(state: TuiState): Line {
|
||||
val connSpans = when {
|
||||
state.connection.reconnecting -> {
|
||||
val secondsRemaining = if (state.connection.nextRetryAtMs != null) {
|
||||
val now = System.currentTimeMillis()
|
||||
maxOf(0, (state.connection.nextRetryAtMs - now) / MS_PER_SECOND)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
"○ reconnecting (attempt ${state.connection.attempt}, retry in ${secondsRemaining}s)"
|
||||
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)")
|
||||
)
|
||||
}
|
||||
state.connection.connected -> "● connected"
|
||||
else -> "○ disconnected"
|
||||
state.connection.connected -> listOf(
|
||||
Span.styled("● ", Style.create().green()),
|
||||
Span.raw("connected")
|
||||
)
|
||||
else -> listOf(
|
||||
Span.styled("○ ", Style.create().dim().gray()),
|
||||
Span.raw("disconnected")
|
||||
)
|
||||
}
|
||||
val providerLabel = if (state.provider.id.isNotEmpty()) {
|
||||
val prov = if (state.provider.id.isNotEmpty()) {
|
||||
"${state.provider.id} (${state.provider.status})"
|
||||
} else {
|
||||
state.provider.status
|
||||
}
|
||||
Text("server: $connectionLabel │ provider: $providerLabel")
|
||||
return Line.from(
|
||||
connSpans + listOf(
|
||||
Span.raw(" │ provider: "),
|
||||
Span.raw(prov)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
|
||||
data class TerminalSize(val width: Int, val height: Int)
|
||||
|
||||
val LocalTerminalSize = staticCompositionLocalOf { TerminalSize(width = 80, height = 24) }
|
||||
|
||||
internal fun readTerminalSize(): TerminalSize = runCatching {
|
||||
val process = ProcessBuilder("stty", "size")
|
||||
.redirectErrorStream(true)
|
||||
.redirectInput(java.io.File("/dev/tty"))
|
||||
.start()
|
||||
val line = process.inputStream.bufferedReader().readLine().orEmpty()
|
||||
process.waitFor()
|
||||
val parts = line.trim().split(" ")
|
||||
TerminalSize(width = parts[1].toInt(), height = parts[0].toInt())
|
||||
}.getOrDefault(TerminalSize(width = 80, height = 24))
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.tui.KeyEvent
|
||||
import com.jakewharton.mosaic.layout.KeyEvent as MosaicKeyEvent
|
||||
|
||||
object MosaicKeyMapper {
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
fun toDomain(event: MosaicKeyEvent): KeyEvent? {
|
||||
if (event.ctrl || event.alt) return null
|
||||
return when (event.key) {
|
||||
"q" -> KeyEvent.Quit
|
||||
"n" -> KeyEvent.NewSession
|
||||
"c" -> KeyEvent.Cancel
|
||||
"a" -> KeyEvent.Approve
|
||||
"r" -> KeyEvent.Reject
|
||||
"s" -> KeyEvent.Steer
|
||||
"/" -> KeyEvent.Filter
|
||||
"ArrowUp" -> KeyEvent.NavUp
|
||||
"ArrowDown" -> KeyEvent.NavDown
|
||||
"Enter" -> KeyEvent.Enter
|
||||
"Backspace" -> KeyEvent.Backspace
|
||||
"Escape" -> KeyEvent.Escape
|
||||
else -> {
|
||||
val ch = event.key.singleOrNull()
|
||||
if (ch != null && !ch.isISOControl()) KeyEvent.CharInput(ch) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.tui.KeyEvent
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import dev.tamboui.tui.event.KeyCode
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "ReturnCount")
|
||||
fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? {
|
||||
if (event.isCtrlC()) return KeyEvent.Quit
|
||||
if (event.hasCtrl() || event.hasAlt()) return null
|
||||
return when (event.code()) {
|
||||
KeyCode.UP -> KeyEvent.NavUp
|
||||
KeyCode.DOWN -> KeyEvent.NavDown
|
||||
KeyCode.ENTER -> KeyEvent.Enter
|
||||
KeyCode.ESCAPE -> KeyEvent.Escape
|
||||
KeyCode.BACKSPACE -> KeyEvent.Backspace
|
||||
KeyCode.TAB -> KeyEvent.Filter
|
||||
KeyCode.CHAR -> {
|
||||
val ch = event.character()
|
||||
if (ch.isISOControl()) return null
|
||||
if (mode != InputMode.None) return KeyEvent.CharInput(ch)
|
||||
when (ch) {
|
||||
'q' -> KeyEvent.Quit
|
||||
'n' -> KeyEvent.NewSession
|
||||
'c' -> KeyEvent.Cancel
|
||||
'a' -> KeyEvent.Approve
|
||||
'r' -> KeyEvent.Reject
|
||||
's' -> KeyEvent.Steer
|
||||
'/' -> KeyEvent.Filter
|
||||
else -> KeyEvent.CharInput(ch)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.tui.KeyEvent
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import dev.tamboui.tui.event.KeyCode
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
import dev.tamboui.tui.event.KeyModifiers
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TambouiKeyMapperTest {
|
||||
|
||||
// --- Ctrl-C always → Quit ---
|
||||
|
||||
@Test
|
||||
fun `ctrl-c maps to Quit in InputMode None`() {
|
||||
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)
|
||||
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-c maps to Quit in InputMode Filter`() {
|
||||
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)
|
||||
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.Filter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-c maps to Quit in InputMode WorkflowId`() {
|
||||
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)
|
||||
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.WorkflowId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl-c maps to Quit in InputMode SteeringNote`() {
|
||||
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)
|
||||
assertEquals(KeyEvent.Quit, mapKey(event, InputMode.SteeringNote))
|
||||
}
|
||||
|
||||
// --- Other Ctrl/Alt → null ---
|
||||
|
||||
@Test
|
||||
fun `ctrl modifier non-ctrlC maps to null`() {
|
||||
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code)
|
||||
assertNull(mapKey(event, InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alt modifier maps to null`() {
|
||||
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code)
|
||||
assertNull(mapKey(event, InputMode.None))
|
||||
}
|
||||
|
||||
// --- KeyCode mappings in InputMode.None ---
|
||||
|
||||
@Test
|
||||
fun `UP maps to NavUp in InputMode None`() {
|
||||
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DOWN maps to NavDown in InputMode None`() {
|
||||
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ENTER maps to Enter in InputMode None`() {
|
||||
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ESCAPE maps to Escape in InputMode None`() {
|
||||
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BACKSPACE maps to Backspace in InputMode None`() {
|
||||
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TAB maps to Filter in InputMode None`() {
|
||||
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.None))
|
||||
}
|
||||
|
||||
// --- KeyCode mappings in InputMode.Filter ---
|
||||
|
||||
@Test
|
||||
fun `UP maps to NavUp in InputMode Filter`() {
|
||||
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.Filter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DOWN maps to NavDown in InputMode Filter`() {
|
||||
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.Filter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ENTER maps to Enter in InputMode Filter`() {
|
||||
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.Filter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ESCAPE maps to Escape in InputMode Filter`() {
|
||||
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.Filter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BACKSPACE maps to Backspace in InputMode Filter`() {
|
||||
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.Filter))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TAB maps to Filter in InputMode Filter`() {
|
||||
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.Filter))
|
||||
}
|
||||
|
||||
// --- Semantic intents in InputMode.None ---
|
||||
|
||||
@Test
|
||||
fun `q maps to Quit in InputMode None`() {
|
||||
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent.ofChar('q'), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `n maps to NewSession in InputMode None`() {
|
||||
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent.ofChar('n'), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `c maps to Cancel in InputMode None`() {
|
||||
assertEquals(KeyEvent.Cancel, mapKey(TambouiKeyEvent.ofChar('c'), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a maps to Approve in InputMode None`() {
|
||||
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent.ofChar('a'), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `r maps to Reject in InputMode None`() {
|
||||
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent.ofChar('r'), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `s maps to Steer in InputMode None`() {
|
||||
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent.ofChar('s'), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slash maps to Filter in InputMode None`() {
|
||||
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofChar('/'), InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other char maps to CharInput in InputMode None`() {
|
||||
assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'), InputMode.None))
|
||||
}
|
||||
|
||||
// --- Regression: semantic chars map to CharInput in non-None modes ---
|
||||
|
||||
@Test
|
||||
fun `q maps to CharInput in InputMode WorkflowId`() {
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.WorkflowId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `n maps to CharInput in InputMode WorkflowId`() {
|
||||
assertEquals(KeyEvent.CharInput('n'), mapKey(TambouiKeyEvent.ofChar('n'), InputMode.WorkflowId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `c maps to CharInput in InputMode WorkflowId`() {
|
||||
assertEquals(KeyEvent.CharInput('c'), mapKey(TambouiKeyEvent.ofChar('c'), InputMode.WorkflowId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a maps to CharInput in InputMode WorkflowId`() {
|
||||
assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'), InputMode.WorkflowId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `r maps to CharInput in InputMode WorkflowId`() {
|
||||
assertEquals(KeyEvent.CharInput('r'), mapKey(TambouiKeyEvent.ofChar('r'), InputMode.WorkflowId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `s maps to CharInput in InputMode WorkflowId`() {
|
||||
assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'), InputMode.WorkflowId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slash maps to CharInput in InputMode WorkflowId`() {
|
||||
assertEquals(KeyEvent.CharInput('/'), mapKey(TambouiKeyEvent.ofChar('/'), InputMode.WorkflowId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `q maps to CharInput in InputMode SteeringNote`() {
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.SteeringNote))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `q maps to CharInput in InputMode Filter`() {
|
||||
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.Filter))
|
||||
}
|
||||
|
||||
// --- ISO control chars in CHAR event → null ---
|
||||
|
||||
@Test
|
||||
fun `ESC codepoint as CHAR event maps to null`() {
|
||||
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B)
|
||||
assertNull(mapKey(event, InputMode.None))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NUL codepoint as CHAR event maps to null`() {
|
||||
val event = TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00)
|
||||
assertNull(mapKey(event, InputMode.None))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user