epic-13: add cli and tui entry point, finish epic.
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user