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 {
|
plugins {
|
||||||
id 'org.jetbrains.kotlin.jvm'
|
id 'org.jetbrains.kotlin.jvm'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
id 'org.jetbrains.kotlin.plugin.compose'
|
|
||||||
id 'application'
|
id 'application'
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
dependencies {
|
||||||
@@ -19,7 +29,11 @@ dependencies {
|
|||||||
implementation "io.ktor:ktor-client-websockets:$ktor_version"
|
implementation "io.ktor:ktor-client-websockets:$ktor_version"
|
||||||
implementation "io.ktor:ktor-client-logging:$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-coroutines-core:$kotlinx_coroutines_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version"
|
||||||
|
|
||||||
@@ -31,6 +45,19 @@ dependencies {
|
|||||||
|
|
||||||
tasks.named('test') { useJUnitPlatform() }
|
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(Tar).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||||
tasks.withType(Zip).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
tasks.withType(Zip).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||||
tasks.named("installDist") { 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
|
package com.correx.apps.tui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import com.correx.apps.tui.components.inputBarLine
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import com.correx.apps.tui.components.renderApprovalPanel
|
||||||
import androidx.compose.runtime.getValue
|
import com.correx.apps.tui.components.sessionListWidget
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import com.correx.apps.tui.components.statusBarLine
|
||||||
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.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.MosaicKeyMapper
|
import com.correx.apps.tui.input.mapKey
|
||||||
import com.correx.apps.tui.reducer.EffectDispatcher
|
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.reducer.RootReducer
|
||||||
|
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 com.jakewharton.mosaic.layout.onKeyEvent
|
import dev.tamboui.layout.Rect
|
||||||
import com.jakewharton.mosaic.modifier.Modifier
|
import dev.tamboui.style.Style
|
||||||
import com.jakewharton.mosaic.runMosaic
|
import dev.tamboui.terminal.Frame
|
||||||
import com.jakewharton.mosaic.ui.Column
|
import dev.tamboui.text.Text
|
||||||
import com.jakewharton.mosaic.ui.Text
|
import dev.tamboui.tui.EventHandler
|
||||||
import kotlinx.coroutines.channels.Channel
|
import dev.tamboui.tui.Renderer
|
||||||
import kotlinx.coroutines.coroutineScope
|
import dev.tamboui.tui.TuiRunner
|
||||||
import kotlinx.coroutines.delay
|
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||||
import kotlinx.coroutines.isActive
|
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.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")
|
fun main(args: Array<String>) {
|
||||||
@Composable
|
val host = args.getOrElse(0) { "localhost" }
|
||||||
private fun Keybinds() {
|
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
|
||||||
Text("n new r resume c cancel a approve q quit ↑↓ navigate")
|
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) {
|
TuiRunner.create().use { runner ->
|
||||||
var state by mutableStateOf(TuiState())
|
fun dispatch(action: Action) {
|
||||||
var quit by mutableStateOf(false)
|
val (next, effects) = RootReducer.reduce(state, action)
|
||||||
var terminalSize by mutableStateOf(readTerminalSize())
|
state = next
|
||||||
|
effects.forEach { effect ->
|
||||||
val effects = Channel<Effect>(Channel.BUFFERED)
|
effectScope.launch {
|
||||||
val ws = TuiWsClient(host = host, port = port)
|
EffectDispatcher(ws) { runner.quit() }.dispatch(effect)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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
|
package com.correx.apps.tui.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import com.correx.apps.tui.state.SessionSummary
|
import com.correx.apps.tui.state.SessionSummary
|
||||||
import com.jakewharton.mosaic.ui.Column
|
import dev.tamboui.layout.Rect
|
||||||
import com.jakewharton.mosaic.ui.Text
|
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")
|
fun renderActiveSession(frame: Frame, session: SessionSummary?, area: Rect) {
|
||||||
@Composable
|
val text = if (session == null) {
|
||||||
fun ActiveSession(session: SessionSummary?) {
|
Text.from(Line.from(Span.raw("(no active session selected)")))
|
||||||
Column {
|
} else {
|
||||||
if (session == null) {
|
Text.from(
|
||||||
Text("(no active session selected)")
|
Line.from(Span.raw("stage: ${session.currentStage ?: "—"}")),
|
||||||
} else {
|
Line.from(Span.raw("last output: ${session.lastOutput ?: "—"}"))
|
||||||
Text("stage: ${session.currentStage ?: "—"}")
|
)
|
||||||
Text("last output: ${session.lastOutput ?: "—"}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
frame.renderWidget(Paragraph.builder().text(text).build(), area)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
package com.correx.apps.tui.components
|
package com.correx.apps.tui.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import com.correx.apps.tui.state.ApprovalInfo
|
import com.correx.apps.tui.state.ApprovalInfo
|
||||||
import com.jakewharton.mosaic.ui.Column
|
import dev.tamboui.layout.Rect
|
||||||
import com.jakewharton.mosaic.ui.Text
|
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")
|
fun renderApprovalPanel(frame: Frame, approval: ApprovalInfo, area: Rect) {
|
||||||
@Composable
|
val dimStyle = Style.create().dim().gray()
|
||||||
fun ApprovalPanel(approval: ApprovalInfo) {
|
val lines = buildList<Line> {
|
||||||
Column {
|
add(Line.from(Span.styled("⚠ APPROVAL REQUIRED — Tier ${approval.tier}", Style.create().yellow().bold())))
|
||||||
Text("⚠ APPROVAL REQUIRED — Tier ${approval.tier}")
|
approval.toolName?.let { add(Line.from(Span.raw("tool: $it"))) }
|
||||||
approval.toolName?.let { Text("tool: $it") }
|
add(Line.from(Span.raw("risk: ${approval.riskSummary}")))
|
||||||
Text("risk: ${approval.riskSummary}")
|
approval.preview?.let { add(Line.from(Span.raw("preview: $it"))) }
|
||||||
approval.preview?.let { Text("preview: $it") }
|
add(Line.from(Span.styled("[A] approve [R] reject", dimStyle)))
|
||||||
Text("[A] approve [R] reject [S] steer")
|
|
||||||
}
|
}
|
||||||
|
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
|
package com.correx.apps.tui.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import com.correx.apps.tui.state.InputMode
|
import com.correx.apps.tui.state.InputMode
|
||||||
import com.correx.apps.tui.state.InputState
|
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")
|
fun inputBarLine(input: InputState): Line? {
|
||||||
@Composable
|
if (input.mode == InputMode.None) return null
|
||||||
fun InputBar(input: InputState) {
|
val (promptText, promptStyle) = when (input.mode) {
|
||||||
val label = when (input.mode) {
|
InputMode.WorkflowId -> "new session › " to Style.create().cyan()
|
||||||
InputMode.None -> ">"
|
InputMode.Filter -> "filter › " to Style.create().yellow()
|
||||||
InputMode.WorkflowId -> "workflow id:"
|
InputMode.SteeringNote -> "steer › " to Style.create().magenta()
|
||||||
InputMode.SteeringNote -> "steering note:"
|
InputMode.None -> "" to Style.create()
|
||||||
InputMode.Filter -> "filter:"
|
|
||||||
}
|
}
|
||||||
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
|
package com.correx.apps.tui.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import com.correx.apps.tui.state.SessionSummary
|
import com.correx.apps.tui.state.SessionSummary
|
||||||
import com.jakewharton.mosaic.ui.Column
|
import com.correx.apps.tui.state.SessionsState
|
||||||
import com.jakewharton.mosaic.ui.Text
|
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_SEC = 1000L
|
||||||
private const val MS_PER_SECOND = 1000L
|
private const val SEC_PER_MIN = 60L
|
||||||
private const val SECS_PER_MINUTE = 60L
|
private const val SEC_PER_HOUR = 3600L
|
||||||
private const val SECS_PER_HOUR = 3600L
|
private const val ID_LEN = 6
|
||||||
|
|
||||||
@Suppress("FunctionNaming")
|
fun sessionListWidget(sessions: SessionsState, listState: ListState): ListWidget {
|
||||||
@Composable
|
val filtered = filteredSessions(sessions)
|
||||||
fun SessionList(sessions: List<SessionSummary>, selectedId: String?, filter: String = "") {
|
val items = filtered.map { ListItem.from(formatSessionItem(it)) }
|
||||||
Column {
|
val selIdx = filtered.indexOfFirst { it.id == sessions.selectedId }.let { if (it < 0) 0 else it }
|
||||||
val filtered = if (filter.isEmpty()) {
|
listState.select(selIdx)
|
||||||
sessions
|
@Suppress("SpreadOperator")
|
||||||
} else {
|
return ListWidget.builder()
|
||||||
sessions.filter { it.workflowId.contains(filter, ignoreCase = true) }
|
.items(*items.toTypedArray())
|
||||||
}
|
.highlightStyle(Style.create().cyan().reversed())
|
||||||
if (filtered.isEmpty()) {
|
.highlightSymbol("▶ ")
|
||||||
Text("(no sessions)")
|
.block(
|
||||||
} else {
|
Block.builder()
|
||||||
filtered.forEach { session ->
|
.title("sessions")
|
||||||
val pointer = if (session.id == selectedId) "▶" else " "
|
.borders(Borders.ALL)
|
||||||
val stage = session.currentStage?.let { " stage $it" } ?: ""
|
.borderType(BorderType.ROUNDED)
|
||||||
val ago = formatAgo(session.lastEventAt)
|
.build()
|
||||||
val shortId = session.id.take(SESSION_ID_DISPLAY_LENGTH)
|
)
|
||||||
Text("$pointer [$shortId] \"${session.workflowId}\" ${session.status}$stage $ago")
|
.build()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatAgo(epochMs: Long): String {
|
fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
|
||||||
val diffMs = System.currentTimeMillis() - epochMs
|
val f = sessions.filter
|
||||||
val secs = diffMs / MS_PER_SECOND
|
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 {
|
return when {
|
||||||
secs < SECS_PER_MINUTE -> "${secs}s ago"
|
secs < SEC_PER_MIN -> "${secs}s ago"
|
||||||
secs < SECS_PER_HOUR -> "${secs / SECS_PER_MINUTE}m ago"
|
secs < SEC_PER_HOUR -> "${secs / SEC_PER_MIN}m ago"
|
||||||
else -> "${secs / SECS_PER_HOUR}h ago"
|
else -> "${secs / SEC_PER_HOUR}h ago"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,41 @@
|
|||||||
package com.correx.apps.tui.components
|
package com.correx.apps.tui.components
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import com.correx.apps.tui.state.TuiState
|
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")
|
fun statusBarLine(state: TuiState): Line {
|
||||||
@Composable
|
val connSpans = when {
|
||||||
fun StatusBar(state: TuiState) {
|
|
||||||
val connectionLabel = when {
|
|
||||||
state.connection.reconnecting -> {
|
state.connection.reconnecting -> {
|
||||||
val secondsRemaining = if (state.connection.nextRetryAtMs != null) {
|
val rem = state.connection.nextRetryAtMs?.let {
|
||||||
val now = System.currentTimeMillis()
|
maxOf(0, (it - System.currentTimeMillis()) / MS_PER_SEC)
|
||||||
maxOf(0, (state.connection.nextRetryAtMs - now) / MS_PER_SECOND)
|
} ?: 0
|
||||||
} else {
|
listOf(
|
||||||
0
|
Span.styled("○ ", Style.create().yellow()),
|
||||||
}
|
Span.raw("reconnecting (attempt ${state.connection.attempt}, retry in ${rem}s)")
|
||||||
"○ reconnecting (attempt ${state.connection.attempt}, retry in ${secondsRemaining}s)"
|
)
|
||||||
}
|
}
|
||||||
state.connection.connected -> "● connected"
|
state.connection.connected -> listOf(
|
||||||
else -> "○ disconnected"
|
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})"
|
"${state.provider.id} (${state.provider.status})"
|
||||||
} else {
|
} else {
|
||||||
state.provider.status
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,11 +51,7 @@
|
|||||||
- **NOT YET WIRED** into `TuiApp.kt` — that is Task 2.3. The old `applyAction`/`applyServerMessage` paths still run.
|
- **NOT YET WIRED** into `TuiApp.kt` — that is Task 2.3. The old `applyAction`/`applyServerMessage` paths still run.
|
||||||
- Decisions deviating from plan: dropped unused `selectedSessionId` param on `InputReducer.reduce`; dropped unused `clock` param on `ConnectionReducer.reduce`. Old `StateReducer.kt` left in place alongside new `ServerMessageReducer.kt` (no callers of `ServerMessageReducer` yet).
|
- Decisions deviating from plan: dropped unused `selectedSessionId` param on `InputReducer.reduce`; dropped unused `clock` param on `ConnectionReducer.reduce`. Old `StateReducer.kt` left in place alongside new `ServerMessageReducer.kt` (no callers of `ServerMessageReducer` yet).
|
||||||
|
|
||||||
---
|
### Task 2.3 — EffectDispatcher + wire RootReducer into TuiApp ✅
|
||||||
|
|
||||||
## Pending — resume here
|
|
||||||
|
|
||||||
### Task 2.3 — EffectDispatcher + wire RootReducer into TuiApp 🚧 NEXT
|
|
||||||
- Create `reducer/EffectDispatcher.kt`.
|
- Create `reducer/EffectDispatcher.kt`.
|
||||||
- Rewrite `ws/TuiWsClient.kt`: expose `messages: SharedFlow<ServerMessage>` and `connection: SharedFlow<ConnectionEvent>`; remove suspend-callback constructor params. Add `sealed interface ConnectionEvent { Connected, Disconnected, RetryScheduled(attempt, nextRetryAtMs) }`.
|
- Rewrite `ws/TuiWsClient.kt`: expose `messages: SharedFlow<ServerMessage>` and `connection: SharedFlow<ConnectionEvent>`; remove suspend-callback constructor params. Add `sealed interface ConnectionEvent { Connected, Disconnected, RetryScheduled(attempt, nextRetryAtMs) }`.
|
||||||
- In `TuiApp`: single `dispatch(action)` → `RootReducer.reduce` → `Snapshot.withMutableSnapshot { state = next }` → trySend each `Effect` to a `Channel<Effect>`.
|
- In `TuiApp`: single `dispatch(action)` → `RootReducer.reduce` → `Snapshot.withMutableSnapshot { state = next }` → trySend each `Effect` to a `Channel<Effect>`.
|
||||||
@@ -63,20 +59,32 @@
|
|||||||
- Delete old `applyAction`/`applyServerMessage` (and `StateReducer.kt` if unused after the cut).
|
- Delete old `applyAction`/`applyServerMessage` (and `StateReducer.kt` if unused after the cut).
|
||||||
- Smoke-test: `n` → workflow id prompt → submit → session starts; approval flow; steering note.
|
- Smoke-test: `n` → workflow id prompt → submit → session starts; approval flow; steering note.
|
||||||
|
|
||||||
### Task 3.1 — `Panel` composable + `TerminalSize`
|
### Task 3.1 — `Panel` composable + `TerminalSize` ✅
|
||||||
- Create `components/Panel.kt` (`@Composable fun Panel(title: String?, content: @Composable () -> Unit)`).
|
- Create `components/Panel.kt` (`@Composable fun Panel(title: String?, content: @Composable () -> Unit)`).
|
||||||
- Create `components/TerminalSize.kt` (data class + `staticCompositionLocalOf { TerminalSize(80, 24) }`).
|
- Create `components/TerminalSize.kt` (data class + `staticCompositionLocalOf { TerminalSize(80, 24) }`).
|
||||||
- Do not wire yet.
|
- Do not wire yet.
|
||||||
|
|
||||||
### Task 3.2 — Migrate components to `Panel`
|
### Task 3.2 — Migrate components to `Panel` ✅
|
||||||
- Wrap each section in `TuiApp` with `Panel(title=…) { … }`.
|
- Wrap each section in `TuiApp` with `Panel(title=…) { … }`.
|
||||||
- Drop every `BOX_WIDTH` constant and hand-padding from `StatusBar`, `SessionList`, `ActiveSession`, `ApprovalPanel`, `InputBar`.
|
- Drop every `BOX_WIDTH` constant and hand-padding from `StatusBar`, `SessionList`, `ActiveSession`, `ApprovalPanel`, `InputBar`.
|
||||||
- `grep -r "BOX_WIDTH" apps/tui` must return nothing.
|
- `grep -r "BOX_WIDTH" apps/tui` must return nothing.
|
||||||
|
|
||||||
### Task 3.3 — Responsive terminal width
|
### Task 3.3 — Responsive terminal width ✅
|
||||||
- Add `readTerminalSize()` (stty fallback, default 80×24) in `TerminalSize.kt`.
|
- Add `readTerminalSize()` (stty fallback, default 80×24) in `TerminalSize.kt`.
|
||||||
- 500ms polling loop in `TuiApp.runTuiApp`; wrap content in `CompositionLocalProvider(LocalTerminalSize provides …)`.
|
- 500ms polling loop in `TuiApp.runTuiApp`; wrap content in `CompositionLocalProvider(LocalTerminalSize provides …)`.
|
||||||
|
|
||||||
|
### Task 4.2 — Session filter via `InputMode.Filter` ✅
|
||||||
|
- `/` enters Filter mode (already wired in resolver). On `SubmitInput` mode=Filter, copy text into `sessions.filter`. On `CancelInput` mode=Filter, clear filter.
|
||||||
|
- `SessionList` renders only sessions whose `workflowId` contains `filter` (case-insensitive).
|
||||||
|
|
||||||
|
### Task 4.3 — Reconnect feedback ✅
|
||||||
|
- In `TuiWsClient.connect`, before backoff `delay`, emit `ConnectionEvent.RetryScheduled(attempt = ++count, nextRetryAtMs = clock() + delayMs)`.
|
||||||
|
- `ConnectionReducer.RetryScheduled` already handles state; `Connected` resets attempt to 0.
|
||||||
|
- `StatusBar` formats `nextRetryAtMs - clock()` as countdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pending — resume here
|
||||||
### Task 4.1 — Scrollable session output log
|
### Task 4.1 — Scrollable session output log
|
||||||
- Extend `SessionsState`: `eventLog: Map<SessionId, ArrayDeque<LogEntry>>`, `scrollOffsetById: Map<SessionId, Int>`.
|
- Extend `SessionsState`: `eventLog: Map<SessionId, ArrayDeque<LogEntry>>`, `scrollOffsetById: Map<SessionId, Int>`.
|
||||||
- New `LogEntry(timestampMs, kind, text)`.
|
- New `LogEntry(timestampMs, kind, text)`.
|
||||||
@@ -84,15 +92,6 @@
|
|||||||
- Rename `ActiveSession.kt` → `SessionOutputLog.kt`; render last N entries with scroll offset.
|
- Rename `ActiveSession.kt` → `SessionOutputLog.kt`; render last N entries with scroll offset.
|
||||||
- Add `Action.ScrollLogUp/Down`; introduce `FocusState { SessionList, OutputLog }`, Tab toggles. Map `NavUp/NavDown` to scroll when focus is on log.
|
- Add `Action.ScrollLogUp/Down`; introduce `FocusState { SessionList, OutputLog }`, Tab toggles. Map `NavUp/NavDown` to scroll when focus is on log.
|
||||||
|
|
||||||
### Task 4.2 — Session filter via `InputMode.Filter`
|
|
||||||
- `/` enters Filter mode (already wired in resolver). On `SubmitInput` mode=Filter, copy text into `sessions.filter`. On `CancelInput` mode=Filter, clear filter.
|
|
||||||
- `SessionList` renders only sessions whose `workflowId` contains `filter` (case-insensitive).
|
|
||||||
|
|
||||||
### Task 4.3 — Reconnect feedback
|
|
||||||
- In `TuiWsClient.connect`, before backoff `delay`, emit `ConnectionEvent.RetryScheduled(attempt = ++count, nextRetryAtMs = clock() + delayMs)`.
|
|
||||||
- `ConnectionReducer.RetryScheduled` already handles state; `Connected` resets attempt to 0.
|
|
||||||
- `StatusBar` formats `nextRetryAtMs - clock()` as countdown.
|
|
||||||
|
|
||||||
### Task 5.1 — Coverage gate
|
### Task 5.1 — Coverage gate
|
||||||
- Add kover verify rule to `apps/tui/build.gradle`: minBound 70 on `com.correx.apps.tui.reducer.*` + `com.correx.apps.tui.input.*`. Confirm syntax against current kover version.
|
- Add kover verify rule to `apps/tui/build.gradle`: minBound 70 on `com.correx.apps.tui.reducer.*` + `com.correx.apps.tui.input.*`. Confirm syntax against current kover version.
|
||||||
|
|
||||||
@@ -104,8 +103,8 @@
|
|||||||
|
|
||||||
## Sequencing
|
## Sequencing
|
||||||
```
|
```
|
||||||
[done] 1.1 → 1.2 → 1.3 → 1.4 → 2.1 → 2.2
|
[done] 1.1 → 1.2 → 1.3 → 1.4 → 2.1 → 2.2 → 2.3 → 3.1 → 3.2 → 3.3 → {4.2, 4.3}
|
||||||
[next] 2.3 → 3.1 → 3.2 → 3.3 → {4.1, 4.2, 4.3} → 5.1 → 5.2
|
[next] 4.1 → 5.1 → 5.2
|
||||||
```
|
```
|
||||||
|
|
||||||
## Renderer evaluation — Mosaic is the floor
|
## Renderer evaluation — Mosaic is the floor
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
# Tamboui TUI Migration
|
||||||
|
|
||||||
|
- **Status:** draft
|
||||||
|
- **Owner:** kami
|
||||||
|
- **Date:** 2026-05-17
|
||||||
|
- **Relates to:** `docs/plans/2026-05-17-tui-refactor.md`, `docs/plans/2026-05-17-tui-refactor.progress.md`
|
||||||
|
- **Supersedes:** Mosaic-based renderer in `apps/tui`
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The current TUI renderer is built on Mosaic 0.18. Mosaic is a Compose-for-terminal runtime
|
||||||
|
that has carried us through the initial event-sourced TUI but caps the level of polish we
|
||||||
|
can ship:
|
||||||
|
|
||||||
|
- No cell buffer — every frame is a string we hand-assemble.
|
||||||
|
- No widget catalog (no list, paragraph, table, scrollbar).
|
||||||
|
- No focus or mouse infrastructure.
|
||||||
|
- No scroll regions or viewport math.
|
||||||
|
- No real layout engine — we hand-draw borders and pad rows.
|
||||||
|
|
||||||
|
The recent TUI refactor (Tasks 2.1–2.3 in the progress log) deliberately split the app
|
||||||
|
into renderer-agnostic subsystems: `input/`, `reducer/`, `state/`, and `ws/` no longer
|
||||||
|
depend on Mosaic. The renderer surface is now small enough to swap.
|
||||||
|
|
||||||
|
A spike on a feature branch (now folded into `master`) proved that tamboui hosts the
|
||||||
|
existing `RootReducer` and `TuiState` without changes. The working spike lives at
|
||||||
|
`apps/tui/src/main/kotlin/com/correx/apps/tui/spike/SpikeMain.kt` (~218 lines) and is
|
||||||
|
the canonical reference for the migration.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Replace Mosaic with tamboui as the renderer for `apps/tui`.
|
||||||
|
- Preserve current UX: status bar, session list with filter, approval flow, steering
|
||||||
|
input, reconnect countdown.
|
||||||
|
- Keep `state/`, `reducer/`, `ws/`, and `input/` (minus the Mosaic key mapper)
|
||||||
|
untouched. The renderer-agnostic core stays renderer-agnostic.
|
||||||
|
- Land the migration on the current `0.2.1-SNAPSHOT` line of tamboui without forking.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No changes to the WebSocket protocol or `TuiWsClient` flows.
|
||||||
|
- No reducer or state-shape changes.
|
||||||
|
- No new features. The scrollable log work in Task 4.1 of the progress log stays its
|
||||||
|
own task and should be redone on top of tamboui rather than ported.
|
||||||
|
- No fork of tamboui to extend its API. Fork-to-pin is the only sanctioned escape hatch
|
||||||
|
if upstream snapshot churn breaks us.
|
||||||
|
- No mouse, no theming engine, no devtools, no snapshot tests for widgets.
|
||||||
|
|
||||||
|
## Library Facts (pin these)
|
||||||
|
|
||||||
|
### Gradle coordinates (Groovy DSL)
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url 'https://central.sonatype.com/repository/maven-snapshots/'
|
||||||
|
mavenContent { snapshotsOnly() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configurations.all {
|
||||||
|
resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maturity
|
||||||
|
|
||||||
|
- License: MIT.
|
||||||
|
- Distribution: snapshot-only.
|
||||||
|
- API stability: experimental, "APIs may/will change."
|
||||||
|
- Bytecode: Java 8. Multi-release jar; `META-INF/versions/11` is module-info only.
|
||||||
|
- JDK 21 (our toolchain) is fine.
|
||||||
|
|
||||||
|
### Stability strategy
|
||||||
|
|
||||||
|
Stay on `0.2.1-SNAPSHOT`. If upstream breaks us, **fork-to-pin** (clone the upstream
|
||||||
|
repo at the known-good commit, `publishToMavenLocal`, swap coordinates). Do **not**
|
||||||
|
fork-to-extend — tamboui is a TUI framework project, not part of correx's scope.
|
||||||
|
|
||||||
|
## Domain Reference
|
||||||
|
|
||||||
|
- **KeyEvent** (`apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt`): sealed class with variants
|
||||||
|
- `object Quit`
|
||||||
|
- `object NewSession`
|
||||||
|
- `object Cancel`
|
||||||
|
- `object Approve`
|
||||||
|
- `object Reject`
|
||||||
|
- `object Steer`
|
||||||
|
- `object NavUp`
|
||||||
|
- `object NavDown`
|
||||||
|
- `object Filter`
|
||||||
|
- `object Enter`
|
||||||
|
- `object Backspace`
|
||||||
|
- `object Escape`
|
||||||
|
- `data class CharInput(val ch: Char)`
|
||||||
|
|
||||||
|
- **Action** (`apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt`): sealed interface with variants
|
||||||
|
- `data object Quit`
|
||||||
|
- `data object OpenNewSessionPrompt`
|
||||||
|
- `data object CancelSelectedSession`
|
||||||
|
- `data object ApproveActive`
|
||||||
|
- `data object RejectActive`
|
||||||
|
- `data object OpenSteeringPrompt`
|
||||||
|
- `data object NavigateUp`
|
||||||
|
- `data object NavigateDown`
|
||||||
|
- `data object OpenFilter`
|
||||||
|
- `data class AppendChar(val ch: Char)`
|
||||||
|
- `data object Backspace`
|
||||||
|
- `data object SubmitInput`
|
||||||
|
- `data object CancelInput`
|
||||||
|
- `data class ServerEventReceived(val message: ServerMessage)`
|
||||||
|
- `data object Connected`
|
||||||
|
- `data object Disconnected`
|
||||||
|
- `data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long)`
|
||||||
|
|
||||||
|
- **InputMode** (`apps/tui/src/main/kotlin/com/correx/apps/tui/state/InputState.kt`): enum with variants `None`, `WorkflowId`, `SteeringNote`, `Filter`
|
||||||
|
|
||||||
|
- **InputState**: `data class InputState(val mode: InputMode = InputMode.None, val text: String = "")`
|
||||||
|
|
||||||
|
- **TuiState** (`apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt`): composition of slices
|
||||||
|
- `connection: ConnectionState`
|
||||||
|
- `sessions: SessionsState`
|
||||||
|
- `input: InputState`
|
||||||
|
- `approval: ApprovalState`
|
||||||
|
- `provider: ProviderState`
|
||||||
|
|
||||||
|
- **SessionsState**: `data class SessionsState(val sessions: List<SessionSummary> = emptyList(), val selectedId: String? = null, val filter: String = "")`
|
||||||
|
|
||||||
|
- **ConnectionState**: `data class ConnectionState(val connected: Boolean = false, val reconnecting: Boolean = false, val attempt: Int = 0, val nextRetryAtMs: Long? = null)`
|
||||||
|
|
||||||
|
- **ProviderState**: `data class ProviderState(val id: String = "", val status: String = "unknown")`
|
||||||
|
|
||||||
|
- **ApprovalState**: `data class ApprovalState(val active: ApprovalInfo? = null)`
|
||||||
|
|
||||||
|
- **Effect** (`apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/Effect.kt`): sealed interface with variants
|
||||||
|
- `data class SendWs(val message: ClientMessage)`
|
||||||
|
- `data object Quit`
|
||||||
|
|
||||||
|
- **EffectDispatcher** (`apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/EffectDispatcher.kt`):
|
||||||
|
- `suspend fun dispatch(effect: Effect)` — takes a single effect and handles it (SendWs via wsClient, Quit via onQuit callback).
|
||||||
|
|
||||||
|
- **RootReducer** (`apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt`):
|
||||||
|
- `fun reduce(state: TuiState, action: Action, clock: () -> Long = System::currentTimeMillis): Pair<TuiState, List<Effect>>`
|
||||||
|
|
||||||
|
- **TuiWsClient** (`apps/tui/src/main/kotlin/com/correx/apps/tui/ws/TuiWsClient.kt`):
|
||||||
|
- `val messages: SharedFlow<ServerMessage>` — emits server messages from the WebSocket stream.
|
||||||
|
- `val connection: SharedFlow<ConnectionEvent>` — emits connection state changes.
|
||||||
|
- **ConnectionEvent** sealed interface variants:
|
||||||
|
- `data object Connected`
|
||||||
|
- `data object Disconnected`
|
||||||
|
- `data class RetryScheduled(val attempt: Int, val nextRetryAtMs: Long)`
|
||||||
|
|
||||||
|
## Verified Tamboui API
|
||||||
|
|
||||||
|
These are the only APIs the migration needs. Implementer agents should not `javap`
|
||||||
|
jars or browse upstream; treat this section as authoritative.
|
||||||
|
|
||||||
|
### Runner
|
||||||
|
|
||||||
|
- `dev.tamboui.tui.TuiRunner` — `TuiRunner.create()` returns an `AutoCloseable`.
|
||||||
|
- Use `TuiRunner.create().use { runner -> runner.run(handler, renderer) }`.
|
||||||
|
- Defaults come from `TuiConfig.defaults()`: rawMode=true, alternateScreen=true,
|
||||||
|
hideCursor=true, mouseCapture=false, pollTimeout=40ms, tickRate=40ms,
|
||||||
|
shutdownHook=true.
|
||||||
|
- `runner.quit()` exits the event loop.
|
||||||
|
- `runner.runOnRenderThread(Runnable)` / `runner.runLater(Runnable)` post work from
|
||||||
|
other threads onto the render thread.
|
||||||
|
|
||||||
|
### Event handler and renderer SAMs
|
||||||
|
|
||||||
|
- `dev.tamboui.tui.EventHandler` — `boolean handle(Event, TuiRunner)`. Return `true`
|
||||||
|
to consume.
|
||||||
|
- `dev.tamboui.tui.Renderer` — `void render(Frame)`.
|
||||||
|
- Both callbacks run on the single render thread; a plain `var state` field is
|
||||||
|
thread-safe as long as nothing else mutates it.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
- `dev.tamboui.tui.event.Event` (sealed) with variants `KeyEvent`, `MouseEvent`,
|
||||||
|
`ResizeEvent`, `TickEvent`.
|
||||||
|
- `KeyEvent` API: `code(): KeyCode`, `character(): Char`, `hasCtrl()`, `hasAlt()`,
|
||||||
|
`hasShift()`, `isCtrlC()`, `isKey(KeyCode)`, `isCharIgnoreCase(Char)`.
|
||||||
|
- `KeyCode` enum: `UP, DOWN, LEFT, RIGHT, ENTER, ESCAPE, BACKSPACE, TAB, CHAR, ...`.
|
||||||
|
|
||||||
|
### Frame and layout
|
||||||
|
|
||||||
|
- `dev.tamboui.terminal.Frame` (not `dev.tamboui.tui.Frame`).
|
||||||
|
- `area(): Rect`, `width(): int`, `height(): int`.
|
||||||
|
- `renderWidget(Widget, Rect)`.
|
||||||
|
- `<S> renderStatefulWidget(StatefulWidget<S>, Rect, S)`.
|
||||||
|
- `dev.tamboui.layout.Rect(x, y, w, h)`; `x()/y()/width()/height()` are methods.
|
||||||
|
- `dev.tamboui.layout.Layout` builder:
|
||||||
|
`Layout.vertical()/horizontal().constraints(Constraint.length(n), Constraint.fill(), Constraint.ratio(a,b)).spacing(n).split(area)` returns `List<Rect>`.
|
||||||
|
Manual `Rect` arithmetic is fine for trivial splits.
|
||||||
|
|
||||||
|
### Widgets
|
||||||
|
|
||||||
|
- `dev.tamboui.widgets.block.Block` — `Block.builder().title(String|Title)
|
||||||
|
.titleBottom(...).borders(Borders.ALL).borderType(BorderType.ROUNDED|PLAIN|DOUBLE|THICK)
|
||||||
|
.style(Style).borderStyle(Style).borderColor(Color).build()`. **Block has no
|
||||||
|
`.widget(...)` method.** Other widgets attach a Block via *their* `.block(...)`.
|
||||||
|
- `dev.tamboui.widgets.paragraph.Paragraph` — `Paragraph.from(String|Line|Text)`
|
||||||
|
factory and `Paragraph.builder().text(String|Line|Text).style(Style).left()/center()/right()
|
||||||
|
.block(Block).foreground(Color).background(Color).build()`.
|
||||||
|
- `dev.tamboui.widgets.list.ListWidget` — `StatefulWidget<ListState>`. Builder:
|
||||||
|
`.items(vararg String | vararg ListItem | List<SizedWidget>)`, `.block(Block)`,
|
||||||
|
`.style(Style)` (unselected), `.highlightStyle(Style)` (selected),
|
||||||
|
`.highlightSymbol(String|Line)`, `.scrollbarThumbStyle(Style)`,
|
||||||
|
`.scrollbarTrackStyle(Style)`, `.itemStyleResolver(BiFunction<Int,Int,Style>)`.
|
||||||
|
- `dev.tamboui.widgets.list.ListState` — *mutable*. `select(Integer): void`,
|
||||||
|
`selectNext(int total)`, `selectPrevious()`. Create once, mutate per frame.
|
||||||
|
- `dev.tamboui.widgets.list.ListItem.from(Line)` builds a styled list row.
|
||||||
|
|
||||||
|
### Styled text
|
||||||
|
|
||||||
|
- `dev.tamboui.text.Span` — `Span.styled(String, Style)`, `Span.raw(String)`.
|
||||||
|
Chainable: `.bold()`, `.italic()`, `.fg(Color)`, named-color shortcuts
|
||||||
|
`.red()/.green()/.cyan()/...`.
|
||||||
|
- `dev.tamboui.text.Line` — `Line.from(vararg Span | List<Span>)`,
|
||||||
|
`Line.styled(String, Style)`. Chainable: `.bold()`, `.fg(Color)`,
|
||||||
|
`.alignment(LEFT|CENTER|RIGHT)`, `.append(Span)`.
|
||||||
|
- `dev.tamboui.text.Text.from(Line)` wraps a `Line` for
|
||||||
|
`Paragraph.Builder.text(Text)`.
|
||||||
|
|
||||||
|
### Style and Color
|
||||||
|
|
||||||
|
- `dev.tamboui.style.Style.create()` builder. Modifiers: `.bold()/.dim()/.italic()
|
||||||
|
/.underlined()/.slowBlink()/.rapidBlink()/.reversed()/.hidden()/.crossedOut()`
|
||||||
|
and their `.notXxx()` negations. Colors: `.fg(Color)/.bg(Color)/.underlineColor(Color)`.
|
||||||
|
Named shortcuts: `.black()/.red()/.green()/.yellow()/.blue()/.magenta()/.cyan()
|
||||||
|
/.white()/.gray()`. Background shortcuts: `.onRed()/.onGreen()/...`.
|
||||||
|
Constant: `Style.EMPTY`.
|
||||||
|
- `dev.tamboui.style.Color` — constants `BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA,
|
||||||
|
CYAN, WHITE, GRAY, DARK_GRAY, LIGHT_RED, LIGHT_GREEN, LIGHT_YELLOW, LIGHT_BLUE,
|
||||||
|
LIGHT_MAGENTA, LIGHT_CYAN, BRIGHT_WHITE, RESET`. Factories: `Color.rgb(r,g,b)`,
|
||||||
|
`Color.hex("#RRGGBB")`, `Color.indexed(0..255)`.
|
||||||
|
- `dev.tamboui.style.Modifier` enum: `NORMAL, BOLD, DIM, ITALIC, UNDERLINED,
|
||||||
|
SLOW_BLINK, RAPID_BLINK, REVERSED, HIDDEN, CROSSED_OUT`.
|
||||||
|
|
||||||
|
## Gradle Launch — Critical
|
||||||
|
|
||||||
|
**`./gradlew run` and any `JavaExec`-backed task are unsupported.** The Gradle daemon
|
||||||
|
has no TTY, JLine falls back to a dumb terminal, escape sequences leak, and rendering
|
||||||
|
breaks. The supported pattern is `installDist` + the generated bin script in a real
|
||||||
|
terminal.
|
||||||
|
|
||||||
|
Current wiring in `apps/tui/build.gradle` already follows this pattern for the spike;
|
||||||
|
keep it but rename the launcher from `spike` to `tui`:
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
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' } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Launch:
|
||||||
|
`./gradlew :apps:tui:installDist && ./apps/tui/build/install/tui/bin/tui`.
|
||||||
|
|
||||||
|
## Integration Patterns (proven by the spike)
|
||||||
|
|
||||||
|
1. **Key mapping.** Translate tamboui `KeyEvent` into the domain `KeyEvent`
|
||||||
|
(`com.correx.apps.tui.KeyEvent`, not under `input/`). The mapper is
|
||||||
|
**mode-aware**: in `InputMode.None`, character keys map to semantic intents
|
||||||
|
(`'q' → Quit`, `'n' → NewSession`, etc.); in every other mode, character
|
||||||
|
keys map to `CharInput(ch)` so they reach the input buffer. This fixes a
|
||||||
|
latent Mosaic bug (`MosaicKeyMapper` is unconditional and silently swallows
|
||||||
|
`q/n/c/a/r/s/'/'` in input modes). The migration must preserve the
|
||||||
|
mode-aware shape and the existing Mosaic mapper should be deleted, not
|
||||||
|
ported verbatim.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun mapKey(e: TambouiKeyEvent, mode: InputMode): DomainKeyEvent? {
|
||||||
|
if (e.isCtrlC()) return DomainKeyEvent.Quit
|
||||||
|
if (e.hasCtrl() || e.hasAlt()) return null
|
||||||
|
return when (e.code()) {
|
||||||
|
KeyCode.UP -> DomainKeyEvent.NavUp
|
||||||
|
KeyCode.DOWN -> DomainKeyEvent.NavDown
|
||||||
|
KeyCode.ENTER -> DomainKeyEvent.Enter
|
||||||
|
KeyCode.ESCAPE -> DomainKeyEvent.Escape
|
||||||
|
KeyCode.BACKSPACE -> DomainKeyEvent.Backspace
|
||||||
|
KeyCode.TAB -> DomainKeyEvent.Filter
|
||||||
|
KeyCode.CHAR -> {
|
||||||
|
val ch = e.character()
|
||||||
|
if (ch.isISOControl()) return null
|
||||||
|
if (mode != InputMode.None) return DomainKeyEvent.CharInput(ch)
|
||||||
|
when (ch) {
|
||||||
|
'q' -> DomainKeyEvent.Quit
|
||||||
|
'n' -> DomainKeyEvent.NewSession
|
||||||
|
'c' -> DomainKeyEvent.Cancel
|
||||||
|
'a' -> DomainKeyEvent.Approve
|
||||||
|
'r' -> DomainKeyEvent.Reject
|
||||||
|
's' -> DomainKeyEvent.Steer
|
||||||
|
'/' -> DomainKeyEvent.Filter
|
||||||
|
else -> DomainKeyEvent.CharInput(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. **Dispatch loop.** `dispatch(action)` calls
|
||||||
|
`RootReducer.reduce(state, action)`, assigns the returned state, and discards
|
||||||
|
effects for now. Real effect plumbing is its own task.
|
||||||
|
3. **List state sync.** Each frame, compute the visual index of
|
||||||
|
`state.sessions.selectedId` within the filtered list and call
|
||||||
|
`listState.select(idx)` before `renderStatefulWidget`. Domain state owns
|
||||||
|
selection; `ListState` is a view detail.
|
||||||
|
4. **Layout shape.** `[status 1 row][sessions fill][input 1 row when mode != None]
|
||||||
|
[help 1 row dim]`.
|
||||||
|
5. **KeyResolver signature.** `KeyResolver.resolve(key, mode, inputText)` takes
|
||||||
|
three arguments; `inputText` is required because `SubmitInput` is suppressed
|
||||||
|
when the input is blank.
|
||||||
|
|
||||||
|
See `apps/tui/src/main/kotlin/com/correx/apps/tui/spike/SpikeMain.kt` for a working
|
||||||
|
end-to-end example.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Files rewritten
|
||||||
|
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/TuiApp.kt`
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/components/StatusBar.kt`
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt`
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/components/InputBar.kt`
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalPanel.kt`
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/components/ActiveSession.kt`
|
||||||
|
|
||||||
|
### Files deleted
|
||||||
|
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/components/Panel.kt` — tamboui
|
||||||
|
`Block` replaces it.
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/components/TerminalSize.kt` —
|
||||||
|
tamboui owns terminal dimensions.
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/input/MosaicKeyMapper.kt` —
|
||||||
|
replaced by a tamboui mapper.
|
||||||
|
- `apps/tui/src/main/kotlin/com/correx/apps/tui/spike/SpikeMain.kt` — folded
|
||||||
|
into `TuiApp.kt` and removed at the end of the migration.
|
||||||
|
|
||||||
|
### Files replaced
|
||||||
|
|
||||||
|
- `input/MosaicKeyMapper.kt` → `input/TambouiKeyMapper.kt` (or folded into
|
||||||
|
`TuiApp.kt` if the mapper stays small).
|
||||||
|
|
||||||
|
### Files untouched
|
||||||
|
|
||||||
|
- Everything under `state/`, `reducer/`, `ws/`.
|
||||||
|
- `input/Action.kt`, `input/KeyResolver.kt`, the domain `input/KeyEvent.kt`.
|
||||||
|
|
||||||
|
### Build changes
|
||||||
|
|
||||||
|
- Drop `com.jakewharton.mosaic:mosaic-runtime`.
|
||||||
|
- Drop `org.jetbrains.kotlin.plugin.compose` from the `apps/tui` plugins list.
|
||||||
|
- Keep ktor, coroutines, kotlinx.serialization.
|
||||||
|
- Add the tamboui dependencies and snapshot repo block above.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- Single-threaded render thread owns `var state: TuiState`.
|
||||||
|
- `EventHandler` reads tamboui events, maps them to domain actions, calls
|
||||||
|
`RootReducer.reduce`, assigns the next state, and dispatches effects to the
|
||||||
|
existing `EffectDispatcher` via a coroutine scope.
|
||||||
|
- `Renderer` reads the current `state` and renders without side effects.
|
||||||
|
- `TuiWsClient` keeps its `SharedFlow<ServerMessage>` and
|
||||||
|
`SharedFlow<ConnectionEvent>`. A coroutine collects them and posts state
|
||||||
|
transitions back to the render thread via `runner.runOnRenderThread { ... }`.
|
||||||
|
Pick one bridging mechanism (runOnRenderThread vs. a channel polled on tick)
|
||||||
|
and use it consistently; the spike uses `runOnRenderThread`.
|
||||||
|
|
||||||
|
### Skeleton
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() = runBlocking {
|
||||||
|
var state = TuiState()
|
||||||
|
val ws = TuiWsClient(host = "localhost", port = 8080)
|
||||||
|
val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
TuiRunner.create().use { runner ->
|
||||||
|
fun dispatch(action: Action) {
|
||||||
|
val (next, effects) = RootReducer.reduce(state, action)
|
||||||
|
state = next
|
||||||
|
effects.forEach { effectScope.launch { EffectDispatcher(ws, { runner.quit() }).dispatch(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WS message bridge: collected on IO, posted to render thread.
|
||||||
|
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) }
|
||||||
|
runner.run(handler, renderer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
Each task is intentionally narrow so `/plan` can decompose it further. Numbers
|
||||||
|
are sequencing hints, not commitments.
|
||||||
|
|
||||||
|
### T1 — Build wiring
|
||||||
|
|
||||||
|
Drop Mosaic and the Compose plugin from `apps/tui/build.gradle`. Add the tamboui
|
||||||
|
snapshot repo and dependencies. Rename the `spike` start script to `tui` and
|
||||||
|
point it at the new `TuiAppKt` main class. Smoke-test `installDist` produces a
|
||||||
|
working bin script (script runs and exits cleanly on the existing spike main as
|
||||||
|
a placeholder).
|
||||||
|
|
||||||
|
### T2 — Tamboui key mapper
|
||||||
|
|
||||||
|
Lift the spike's `mapKey` into `input/TambouiKeyMapper.kt`. Add unit tests
|
||||||
|
covering every `(KeyCode, character, modifiers)` combination that
|
||||||
|
`KeyResolver.resolve` consumes across every `InputMode`. Tests are pure JVM and
|
||||||
|
do not start the runner.
|
||||||
|
|
||||||
|
### T3 — TuiApp shell
|
||||||
|
|
||||||
|
Rewrite `TuiApp.kt` around `TuiRunner.create().use { ... }`. Wire the event
|
||||||
|
handler dispatch loop, the render-thread state cell, and the coroutine bridges
|
||||||
|
for WS messages, connection events, and effects. Preserve the existing
|
||||||
|
`EffectDispatcher` contract.
|
||||||
|
|
||||||
|
### T4 — StatusBar
|
||||||
|
|
||||||
|
Rewrite as a function returning a `Line` containing the connection badge and
|
||||||
|
provider/session segments. Use `Span.styled` + named-color shortcuts; no
|
||||||
|
manual padding.
|
||||||
|
|
||||||
|
### T5 — SessionList
|
||||||
|
|
||||||
|
Rewrite as a `ListWidget` with `ListItem.from(Line)` rows. Per-status color
|
||||||
|
comes from `Style.create().<color>()`. Selection highlight via
|
||||||
|
`.highlightStyle(...)` and `.highlightSymbol(...)`. Drive selection from
|
||||||
|
domain state per Integration Pattern 3.
|
||||||
|
|
||||||
|
**Status color mapping** (from `formatItem` in the spike):
|
||||||
|
- `running` → `Style.create().green()`
|
||||||
|
- `error` → `Style.create().red()`
|
||||||
|
- `paused` → `Style.create().yellow()`
|
||||||
|
- `completed` → `Style.create().dim().gray()`
|
||||||
|
- `idle` → `Style.create().cyan()`
|
||||||
|
- (default/unknown) → `Span.raw(s.status)` (no color)
|
||||||
|
|
||||||
|
**Row composition** (from spike `formatItem`):
|
||||||
|
- `[id]` segment: dim gray style, ID truncated to 6 chars, e.g., `Span.styled("[s1a2b3]", dimStyle)`
|
||||||
|
- workflow ID segment: default style, quoted, e.g., `"wf-build-frontend"`
|
||||||
|
- status segment: per-status color (see table above)
|
||||||
|
- stage segment (if present): default style, e.g., ` stage compile`
|
||||||
|
- ago segment: dim gray style, e.g., `12s ago` via `formatAgo()`
|
||||||
|
|
||||||
|
**List widget configuration**:
|
||||||
|
- `.highlightSymbol("▶ ")` — highlight prefix
|
||||||
|
- `.highlightStyle(Style.create().cyan().reversed())` — selection styling
|
||||||
|
|
||||||
|
### T6 — InputBar
|
||||||
|
|
||||||
|
Rewrite as a one-row paragraph with a mode-tinted prompt prefix and a visible
|
||||||
|
cursor. Hide when `mode == None`.
|
||||||
|
|
||||||
|
### T7 — ApprovalPanel
|
||||||
|
|
||||||
|
Rewrite as a `Block` (rounded borders, accent border color) wrapping a
|
||||||
|
`Paragraph` whose body is a styled `Text`. Confirm/cancel hints styled `dim`.
|
||||||
|
|
||||||
|
### T8 — ActiveSession / scrollable log
|
||||||
|
|
||||||
|
Rewrite the active-session view minimally. Task 4.1 in the progress log
|
||||||
|
introduces a real scrollable log; **do that on top of tamboui after this
|
||||||
|
migration lands**, not before. Carrying Mosaic-shaped log code into tamboui is
|
||||||
|
explicitly wasted work.
|
||||||
|
|
||||||
|
### T9 — Delete dead code
|
||||||
|
|
||||||
|
Remove `Panel.kt`, `TerminalSize.kt`, `MosaicKeyMapper.kt`, every
|
||||||
|
`@Composable` annotation and Mosaic import, and `spike/SpikeMain.kt`. Verify
|
||||||
|
no references remain (`grep -r mosaic apps/tui/`).
|
||||||
|
|
||||||
|
### T10 — Quality gates
|
||||||
|
|
||||||
|
Restore detekt and kover gates on `apps/tui`. Run `./gradlew :apps:tui:check`.
|
||||||
|
Smoke-test the produced bin script against a real running `apps/server` and
|
||||||
|
walk every UX path (status, list, filter, approval, steering, reconnect).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Reducer and key-mapper tests remain pure JVM and run in CI as today.
|
||||||
|
- Render-level tests are out of scope for v1. Tamboui exposes an inspectable
|
||||||
|
`Buffer`, but widget-level snapshot tests are not worth the maintenance cost
|
||||||
|
before the API stabilizes. Manual smoke + the existing reducer coverage are
|
||||||
|
the contract.
|
||||||
|
- **Manual smoke is always `installDist` + bin script.** Never `./gradlew run`.
|
||||||
|
|
||||||
|
## Risks and Open Questions
|
||||||
|
|
||||||
|
- **Snapshot dependency churn.** Mitigation: fork-to-pin if breakage occurs;
|
||||||
|
do not fork-to-extend.
|
||||||
|
- **Deferred features.** Mouse, scroll widgets, and richer focus handling are
|
||||||
|
not promised in v1; they belong to a later epic.
|
||||||
|
- **Mosaic mapper bug — fixed in the spike, must not regress.** Character
|
||||||
|
keys (`q/n/c/a/r/s/'/'`) were eagerly mapped to semantic intents and
|
||||||
|
silently swallowed inside input modes. The spike's `mapKey` takes the
|
||||||
|
current `InputMode` and only does the semantic translation in
|
||||||
|
`InputMode.None`. T2 must keep this shape and add a regression test.
|
||||||
|
- **Effect-dispatch bridging mechanism.** Spike uses `runOnRenderThread`. If
|
||||||
|
back-pressure or ordering becomes a problem, switch to a bounded channel
|
||||||
|
polled on `TickEvent`. Decision deferred to T3.
|
||||||
|
|
||||||
|
## Out-of-Scope Follow-ups
|
||||||
|
|
||||||
|
- Mouse support.
|
||||||
|
- Tree widget for hierarchical workflow view.
|
||||||
|
- CSS-like theming.
|
||||||
|
- Devtools and widget snapshot testing.
|
||||||
|
- Task 4.1 scrollable log (do after migration).
|
||||||
|
- Fix for the inherited "char keys swallowed in input mode" bug.
|
||||||
Reference in New Issue
Block a user