epic-13: add cli and tui entry point, finish epic.

This commit is contained in:
2026-05-16 13:37:58 +04:00
parent 72d20726ce
commit 2207a37549
60 changed files with 2896 additions and 41 deletions
+24
View File
@@ -0,0 +1,24 @@
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'
}
dependencies {
implementation project(':apps:server')
implementation project(':core:events')
implementation project(':core:approvals')
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-websockets:$ktor_version"
implementation "com.jakewharton.mosaic:mosaic-runtime:0.13.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version"
}
@@ -0,0 +1,12 @@
package com.correx.apps.tui
sealed class KeyEvent {
object Quit : KeyEvent()
object NewSession : KeyEvent()
object Cancel : KeyEvent()
object Approve : KeyEvent()
object Reject : KeyEvent()
object Steer : KeyEvent()
object NavUp : KeyEvent()
object NavDown : KeyEvent()
}
@@ -0,0 +1,11 @@
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) }
}
@@ -0,0 +1,89 @@
package com.correx.apps.tui
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.TuiState
@Suppress("CyclomaticComplexMethod")
internal fun applyServerMessage(msg: ServerMessage, state: TuiState): TuiState = when (msg) {
is ServerMessage.SessionStarted -> applySessionStarted(msg, state)
is ServerMessage.SessionPaused -> applySessionPaused(msg, state)
is ServerMessage.SessionCompleted -> touchSession(state, msg.sessionId.value, status = "COMPLETED")
is ServerMessage.SessionFailed -> touchSession(state, msg.sessionId.value, status = "FAILED")
is ServerMessage.StageStarted -> state.copy(sessions = state.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(currentStage = msg.stageId.value, lastEventAt = now())
} else {
s
}
})
is ServerMessage.StageCompleted -> touchSession(state, msg.sessionId.value)
is ServerMessage.StageFailed -> touchSession(state, msg.sessionId.value)
is ServerMessage.InferenceCompleted -> state.copy(sessions = state.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(lastOutput = msg.outputSummary, lastEventAt = now()) else s
})
is ServerMessage.ToolCompleted -> state.copy(sessions = state.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(lastOutput = "${msg.toolName}: ${msg.outputSummary}", lastEventAt = now())
} else {
s
}
})
is ServerMessage.ApprovalRequired -> {
val info = ApprovalInfo(
requestId = msg.requestId.value,
sessionId = msg.sessionId.value,
tier = msg.tier.name,
riskSummary = msg.riskSummary.level,
toolName = msg.toolName,
preview = msg.preview,
)
state.copy(activeApproval = info)
}
is ServerMessage.ProviderStatusChanged -> state.copy(
providerId = msg.status.providerId,
providerStatus = msg.status.status,
)
else -> state
}
private fun applySessionStarted(msg: ServerMessage.SessionStarted, state: TuiState): TuiState {
val summary = SessionSummary(
id = msg.sessionId.value,
status = "ACTIVE",
workflowId = msg.workflowId,
lastEventAt = now(),
currentStage = null,
lastOutput = null,
)
val selected = state.selectedSessionId ?: msg.sessionId.value
return state.copy(sessions = state.sessions + summary, selectedSessionId = selected)
}
private fun applySessionPaused(msg: ServerMessage.SessionPaused, state: TuiState): TuiState {
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
"PAUSED awaiting approval"
} else {
"PAUSED"
}
return state.copy(sessions = state.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(status = statusLabel, lastEventAt = now())
} else {
s
}
})
}
private fun touchSession(state: TuiState, sessionId: String, status: String? = null): TuiState =
state.copy(sessions = state.sessions.map { s ->
if (s.id == sessionId) {
if (status != null) s.copy(status = status, lastEventAt = now()) else s.copy(lastEventAt = now())
} else {
s
}
})
internal fun now(): Long = System.currentTimeMillis()
@@ -0,0 +1,191 @@
package com.correx.apps.tui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
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.SessionList
import com.correx.apps.tui.components.StatusBar
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.ws.TuiWsClient
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.jakewharton.mosaic.runMosaic
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
suspend fun runTuiApp(host: String, port: Int) {
var state by mutableStateOf(TuiState())
val keyChannel = Channel<KeyEvent>(capacity = Channel.BUFFERED)
val wsClient = TuiWsClient(
host = host,
port = port,
onConnected = { state = state.copy(connected = true, reconnecting = false) },
onDisconnected = { state = state.copy(connected = false, reconnecting = true) },
onMessage = { msg -> state = applyServerMessage(msg, state) },
)
runMosaic {
setContent {
Column {
Text("─── status ───────────────────────────────────────────────")
StatusBar(state)
Text("─── sessions ─────────────────────────────────────────────")
SessionList(state.sessions, state.selectedSessionId)
Text("─── active session ───────────────────────────────────────")
ActiveSession(state.sessions.find { it.id == state.selectedSessionId })
if (state.activeApproval != null) {
Text("─── approval ─────────────────────────────────────────────")
ApprovalPanel(state.activeApproval)
}
Text("─────────────────────────────────────────────────────────")
InputBar(state.inputText)
Text("q:quit n:new c:cancel ↑↓:navigate a/r/s:approval")
}
}
launch { wsClient.connect() }
launch { readKeys(keyChannel) }
launch { handleKeys(keyChannel, wsClient, getState = { state }, setState = { state = it }) }
}
wsClient.close()
}
private suspend fun readKeys(keyChannel: Channel<KeyEvent>) {
withContext(Dispatchers.IO) {
val stdin = System.`in`
var running = true
while (running) {
val b = stdin.read()
if (b == -1) break
val event = charToKeyEvent(b.toChar(), stdin)
if (event != null) {
keyChannel.send(event)
if (event is KeyEvent.Quit) running = false
}
}
}
}
@Suppress("MagicNumber")
private fun charToKeyEvent(ch: Char, stdin: java.io.InputStream): KeyEvent? = when (ch) {
'q' -> KeyEvent.Quit
'n' -> KeyEvent.NewSession
'c' -> KeyEvent.Cancel
'a' -> KeyEvent.Approve
'r' -> KeyEvent.Reject
's' -> KeyEvent.Steer
'k', '' -> {
val next = stdin.read()
if (next == '['.code) {
when (stdin.read().toChar()) {
'A' -> KeyEvent.NavUp
'B' -> KeyEvent.NavDown
else -> null
}
} else {
null
}
}
else -> null
}
@Suppress("CyclomaticComplexMethod")
private suspend fun handleKeys(
keyChannel: Channel<KeyEvent>,
wsClient: TuiWsClient,
getState: () -> TuiState,
setState: (TuiState) -> Unit,
) {
for (event in keyChannel) {
val state = getState()
when (event) {
is KeyEvent.Quit -> { wsClient.close(); return }
is KeyEvent.NewSession -> handleNewSession(wsClient)
is KeyEvent.Cancel -> handleCancel(state, wsClient)
is KeyEvent.Approve -> handleApproval(state, wsClient, ApprovalDecision.APPROVE, null, setState)
is KeyEvent.Reject -> handleApproval(state, wsClient, ApprovalDecision.REJECT, null, setState)
is KeyEvent.Steer -> handleSteer(state, wsClient, setState)
is KeyEvent.NavUp -> setState(navigateUp(state))
is KeyEvent.NavDown -> setState(navigateDown(state))
}
}
}
private suspend fun handleNewSession(wsClient: TuiWsClient) {
val workflow = withContext(Dispatchers.IO) {
System.console()?.readLine("workflow id: ")
} ?: return
wsClient.send(ClientMessage.StartSession(workflowId = workflow, config = null))
}
private suspend fun handleCancel(state: TuiState, wsClient: TuiWsClient) {
state.selectedSessionId?.let { id ->
wsClient.send(ClientMessage.CancelSession(sessionId = SessionId(id)))
}
}
private suspend fun handleApproval(
state: TuiState,
wsClient: TuiWsClient,
decision: ApprovalDecision,
note: String?,
setState: (TuiState) -> Unit,
) {
state.activeApproval?.let { info ->
wsClient.send(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(info.requestId),
decision = decision,
steeringNote = note,
)
)
setState(state.copy(activeApproval = null))
}
}
private suspend fun handleSteer(
state: TuiState,
wsClient: TuiWsClient,
setState: (TuiState) -> Unit,
) {
state.activeApproval?.let { info ->
val note = withContext(Dispatchers.IO) {
System.console()?.readLine("steering note: ")
}
wsClient.send(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(info.requestId),
decision = ApprovalDecision.STEER,
steeringNote = note,
)
)
setState(state.copy(activeApproval = null))
}
}
private fun navigateUp(state: TuiState): TuiState {
val sessions = state.sessions
if (sessions.isEmpty()) return state
val idx = sessions.indexOfFirst { it.id == state.selectedSessionId }
val newIdx = if (idx <= 0) sessions.lastIndex else idx - 1
return state.copy(selectedSessionId = sessions[newIdx].id)
}
private fun navigateDown(state: TuiState): TuiState {
val sessions = state.sessions
if (sessions.isEmpty()) return state
val idx = sessions.indexOfFirst { it.id == state.selectedSessionId }
val newIdx = if (idx >= sessions.lastIndex) 0 else idx + 1
return state.copy(selectedSessionId = sessions[newIdx].id)
}
@@ -0,0 +1,19 @@
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
@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 ?: "—"}")
}
}
}
@@ -0,0 +1,19 @@
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
@Suppress("FunctionNaming")
@Composable
fun ApprovalPanel(approval: ApprovalInfo?) {
if (approval == null) return
Column {
Text("⚠ APPROVAL REQUIRED — Tier ${approval.tier}")
Text("risk: ${approval.riskSummary}")
approval.toolName?.let { Text("tool: $it") }
approval.preview?.let { Text("preview: $it") }
Text("[A] approve [R] reject [S] steer")
}
}
@@ -0,0 +1,10 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.jakewharton.mosaic.ui.Text
@Suppress("FunctionNaming")
@Composable
fun InputBar(text: String) {
Text("> ${text}_")
}
@@ -0,0 +1,39 @@
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
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
@Suppress("FunctionNaming")
@Composable
fun SessionList(sessions: List<SessionSummary>, selectedId: String?) {
Column {
if (sessions.isEmpty()) {
Text(" (no sessions)")
} else {
sessions.forEach { session ->
val prefix = 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("$prefix [$shortId] \"${session.workflowId}\" ${session.status}$stage $ago")
}
}
}
}
private fun formatAgo(epochMs: Long): String {
val diffMs = System.currentTimeMillis() - epochMs
val secs = diffMs / MS_PER_SECOND
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"
}
}
@@ -0,0 +1,21 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.TuiState
import com.jakewharton.mosaic.ui.Text
@Suppress("FunctionNaming")
@Composable
fun StatusBar(state: TuiState) {
val connectionLabel = when {
state.reconnecting -> "reconnecting..."
state.connected -> "● connected"
else -> "○ disconnected"
}
val providerLabel = if (state.providerId.isNotEmpty()) {
"${state.providerId} (${state.providerStatus})"
} else {
state.providerStatus
}
Text("server: $connectionLabel │ provider: $providerLabel")
}
@@ -0,0 +1,30 @@
package com.correx.apps.tui.state
data class TuiState(
val connected: Boolean = false,
val reconnecting: Boolean = false,
val sessions: List<SessionSummary> = emptyList(),
val selectedSessionId: String? = null,
val activeApproval: ApprovalInfo? = null,
val inputText: String = "",
val providerStatus: String = "unknown",
val providerId: String = "",
)
data class SessionSummary(
val id: String,
val status: String,
val workflowId: String,
val lastEventAt: Long,
val currentStage: String?,
val lastOutput: String?,
)
data class ApprovalInfo(
val requestId: String,
val sessionId: String,
val tier: String,
val riskSummary: String,
val toolName: String?,
val preview: String?,
)
@@ -0,0 +1,73 @@
package com.correx.apps.tui.ws
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ServerMessage
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.delay
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val INITIAL_RETRY_DELAY_MS = 1_000L
private const val MAX_RETRY_DELAY_MS = 30_000L
class TuiWsClient(
private val host: String,
private val port: Int,
private val onConnected: suspend () -> Unit,
private val onDisconnected: suspend () -> Unit,
private val onMessage: suspend (ServerMessage) -> Unit,
) {
private val json = Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
}
private val client = HttpClient(CIO) {
install(WebSockets)
}
private var session: WebSocketSession? = null
suspend fun send(message: ClientMessage) {
runCatching {
session?.send(json.encodeToString(message))
}
}
suspend fun connect() {
var delayMs = INITIAL_RETRY_DELAY_MS
while (true) {
runCatching {
client.webSocket(host = host, port = port, path = "/stream") {
session = this
onConnected()
delayMs = INITIAL_RETRY_DELAY_MS
for (frame in incoming) {
if (frame is Frame.Text) {
runCatching {
json.decodeFromString<ServerMessage>(frame.readText())
}.onSuccess { msg ->
onMessage(msg)
}
}
}
}
}
session = null
onDisconnected()
delay(delayMs)
delayMs = minOf(delayMs * 2, MAX_RETRY_DELAY_MS)
}
}
fun close() {
client.close()
}
}